diff --git a/.conner b/.conner new file mode 100644 index 0000000..72ee096 --- /dev/null +++ b/.conner @@ -0,0 +1,7 @@ +;;; -*- lisp-data -*- +((:name "Test with coverage" + :command "pytest --cov" + :type "compile") + (:name "Run" + :command "python src/main.py" + :type "compile")) diff --git a/.gitignore b/.gitignore index eeb8a6e..f302cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ **/__pycache__ +**/.coverage diff --git a/src/appinfo.py b/src/appinfo.py deleted file mode 100644 index eaca12c..0000000 --- a/src/appinfo.py +++ /dev/null @@ -1,331 +0,0 @@ -# A Metadata Editor for Steam Applications -# Copyright (C) 2023 Tomás Ralph -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os -from hashlib import sha1 -from struct import pack, unpack - - -class IncompatibleVDFError(Exception): - def __init__(self, vdf_version): - self.vdf_version = vdf_version - - -class Appinfo: - def __init__(self, vdf_path, choose_apps=False, apps=None): - self.offset = 0 - - self.vdf_path = vdf_path - - self.COMPATIBLE_VERSIONS = [0x107564428] - - self.SEPARATOR = b"\x00" - self.TYPE_DICT = b"\x00" - self.TYPE_STRING = b"\x01" - self.TYPE_INT32 = b"\x02" - self.SECTION_END = b"\x08" - - self.INT_SEPARATOR = int.from_bytes(self.SEPARATOR, "little") - self.INT_TYPE_DICT = int.from_bytes(self.TYPE_DICT, "little") - self.INT_TYPE_STRING = int.from_bytes(self.TYPE_STRING, "little") - self.INT_TYPE_INT32 = int.from_bytes(self.TYPE_INT32, "little") - self.INT_SECTION_END = int.from_bytes(self.SECTION_END, "little") - - with open(self.vdf_path, "rb") as vdf: - self.appinfoData = bytearray(vdf.read()) - - self.verify_vdf_version() - - # Load only the modified apps - if choose_apps: - self.parsedAppInfo = {} - for app in apps: - self.parsedAppInfo[app] = self.read_app(app) - else: - self.parsedAppInfo = self.read_all_apps() - - def read_string(self): - str_end = self.appinfoData.find(self.INT_SEPARATOR, self.offset) - try: - string = self.appinfoData[self.offset:str_end].decode("utf-8") - except UnicodeDecodeError: - string = self.appinfoData[self.offset:str_end].decode("latin-1") - self.offset += str_end - self.offset + 1 - return string - - def read_int64(self): - int64 = unpack(" None: + fp.write(dumps(obj)) + +def dumps(obj: dict) -> bytearray: + return AppinfoEncoder(obj).encode() + +def load(fp) -> dict: + return AppinfoDecoder(fp.read()).decode() + +def loads(file_path: str) -> dict: + with open(file_path, "rb") as f: + return load(f) diff --git a/src/appinfo/common.py b/src/appinfo/common.py new file mode 100644 index 0000000..ff75a62 --- /dev/null +++ b/src/appinfo/common.py @@ -0,0 +1,24 @@ +import struct + + +COMPATIBLE_MAGIC_NUMBERS = [ 0x07564428 ] +COMPATIBLE_UNIVERSES = [ 0x01 ] + +HEADER_FORMAT = "<4IQ20sI20s" +HEADER_SIZE = struct.calcsize(HEADER_FORMAT) + +LAST_APPID = 0x00 + +SEPARATOR = b"\x00" +TYPE_DICT = b"\x00" +TYPE_STRING = b"\x01" +TYPE_INT32 = b"\x02" +TYPE_INT64 = b"\x07" +SECTION_END = b"\x08" + +INT_SEPARATOR = int.from_bytes(SEPARATOR, "little") +INT_TYPE_DICT = int.from_bytes(TYPE_DICT, "little") +INT_TYPE_STRING = int.from_bytes(TYPE_STRING, "little") +INT_TYPE_INT32 = int.from_bytes(TYPE_INT32, "little") +INT_TYPE_INT64 = int.from_bytes(TYPE_INT64, "little") +INT_SECTION_END = int.from_bytes(SECTION_END, "little") diff --git a/src/appinfo/decoder.py b/src/appinfo/decoder.py new file mode 100644 index 0000000..5605590 --- /dev/null +++ b/src/appinfo/decoder.py @@ -0,0 +1,110 @@ +import struct +from .common import (HEADER_FORMAT, + HEADER_SIZE, + INT_SEPARATOR, + INT_TYPE_DICT, + INT_TYPE_STRING, + INT_TYPE_INT32, + INT_TYPE_INT64, + INT_SECTION_END, + LAST_APPID, + COMPATIBLE_MAGIC_NUMBERS, + COMPATIBLE_UNIVERSES) + + +class AppinfoDecodeError(Exception): + pass + + +class AppinfoDecoder: + def __init__(self, contents: bytearray): + self.contents = contents + self.pointer = 0 + self._decoders = { + INT_TYPE_DICT: self._read_app_content, + INT_TYPE_STRING: self._read_string, + INT_TYPE_INT32: self._read_int32, + INT_TYPE_INT64: self._read_int64, + } + + def decode(self) -> dict: + self._validate_vdf_version() + return { + "apps": self._read_all_apps() + } + + def _is_magic_number_valid(version: int) -> bool: + return version in COMPATIBLE_MAGIC_NUMBERS + + def _is_universe_valid(universe: int) -> bool: + return universe in COMPATIBLE_UNIVERSES + + def _validate_vdf_version(self): + magic = self._read_int32() + universe = self._read_int32() + if not AppinfoDecoder._is_magic_number_valid(magic) or \ + not AppinfoDecoder._is_universe_valid(universe): + raise AppinfoDecodeError(f"Invalid VDF version: Magic: {magic}, Universe: {universe}") + + def _read(self, count: int) -> bytearray: + result = self.contents[self.pointer:self.pointer + count] + self.pointer += count + return result + + def _read_byte(self) -> int: + return int.from_bytes(self._read(1)) + + def _read_int32(self) -> int: + return struct.unpack(" int: + return struct.unpack(" bytes: + str_end = self.contents.find(INT_SEPARATOR, self.pointer) + string = self._read(str_end - self.pointer) + self._read_byte() + return string.decode("utf-8") + + def _peek_appid(self): + current_pointer = self.pointer + next_appid = self._read_int32() + self.pointer = current_pointer + return next_appid + + def _read_app_header(self) -> dict: + header_content = struct.unpack(HEADER_FORMAT, self._read(HEADER_SIZE)) + return { + "appid" : header_content[0], + "size" : header_content[1], + "state" : header_content[2], + "last_update" : header_content[3], + "access_token" : header_content[4], + "checksum_text" : header_content[5], + "change_number" : header_content[6], + "checksum_binary" : header_content[7], + } + + def _read_app_content(self) -> dict: + content = {} + while True: + value_type = self._read_byte() + if value_type == INT_SECTION_END: break + key = self._read_string() + value = self._decoders[value_type]() + content[key] = value + return content + + def _read_app(self) -> dict: + return { + "header": self._read_app_header(), + "content": self._read_app_content(), + } + + def _read_all_apps(self) -> dict: + apps = {} + while True: + appid = self._peek_appid() + if appid == LAST_APPID: break + apps[appid] = self._read_app() + return apps diff --git a/src/appinfo/encoder.py b/src/appinfo/encoder.py new file mode 100644 index 0000000..9c7ca97 --- /dev/null +++ b/src/appinfo/encoder.py @@ -0,0 +1,98 @@ +import struct +import textvdf +from hashlib import sha1 +from .common import (HEADER_FORMAT, + HEADER_SIZE, + SEPARATOR, + TYPE_DICT, + TYPE_STRING, + TYPE_INT32, + TYPE_INT64, + SECTION_END, + LAST_APPID, + COMPATIBLE_MAGIC_NUMBERS, + COMPATIBLE_UNIVERSES) + + +class AppinfoEncoder: + def __init__(self, obj: dict=None): + self.obj = obj + + def encode(self) -> bytearray: + result = bytearray() + result += self._encode_int32(COMPATIBLE_MAGIC_NUMBERS[0]) + result += self._encode_int32(COMPATIBLE_UNIVERSES[0]) + result += self._encode_all_apps(self.obj["apps"]) + result += self._encode_int32(LAST_APPID) + return result + + def _encode_int32(self, integer: int) -> bytearray: + return struct.pack(" bytearray: + return struct.pack(" bytearray: + return string.encode() + SEPARATOR + + def _encode_header(self, header: dict) -> bytearray: + return struct.pack(HEADER_FORMAT, + header["appid"], + header["size"], + header["state"], + header["last_update"], + header["access_token"], + header["checksum_text"], + header["change_number"], + header["checksum_binary"]) + + def _encode_app_content(self, app_content: dict) -> bytearray: + encoded_content = bytearray() + for key, value in app_content.items(): + if isinstance(value, str): + encoded_content += ( + TYPE_STRING + + self._encode_string(key) + + self._encode_string(value)) + elif isinstance(value, int): + encoded_content += ( + TYPE_INT32 + + self._encode_string(key) + + self._encode_int32(value)) + elif isinstance(value, dict): + encoded_content += ( + TYPE_DICT + + self._encode_string(key) + + self._encode_app_content(value)) + encoded_content += SECTION_END + return encoded_content + + def _encode_app(self, app: dict) -> bytearray: + result = bytearray() + encoded_content = self._encode_app_content(app["content"]) + self._update_app_header(app, encoded_content) + result += self._encode_header(app["header"]) + result += encoded_content + return result + + def _encode_all_apps(self, apps: dict) -> bytearray: + result = bytearray() + for app in apps.values(): + result += self._encode_app(app) + return result + + def _update_app_header(self, app: dict, encoded_content: bytearray): + # 8 is the number of bytes the appid and size sections take, + # which are not taken into account for the size calculation + app["header"]["size"] = len(encoded_content) + HEADER_SIZE - 8 + app["header"]["checksum_text"] = self._get_checksum_text(app["content"]) + app["header"]["checksum_binary"] = self._get_checksum_binary(encoded_content) + + def _get_checksum_text(self, app_contents: dict) -> bytes: + text_vdf = textvdf.dumps(app_contents) + hash = sha1(text_vdf.encode()) + return hash.digest() + + def _get_checksum_binary(self, encoded_app: bytearray) -> bytes: + hash = sha1(encoded_app) + return hash.digest() diff --git a/src/appinfo/tests/__init__.py b/src/appinfo/tests/__init__.py new file mode 100644 index 0000000..e854c48 --- /dev/null +++ b/src/appinfo/tests/__init__.py @@ -0,0 +1,20 @@ +import os + +APPINFO_MOCK_DIR = os.path.join(os.path.dirname(__file__), "appinfo_mocks") + +TEST_APP_HEADER = { + "appid" : 0x6969, + "size" : 0x52, + "state" : 0x1, + "last_update" : 0x153, + "access_token" : 0x23, + "checksum_text" : b"lT\xccD\xdd6\xc4\x0f '7_m\x99\x02\xcaYRV\xdf", + "change_number" : 0x64, + "checksum_binary" : b'O\x9e\x8aI\x9aD\xcf\x12\x140{\xb4\xef\xb9\xb5c{\x97x\x94', +} + +TEST_APP_CONTENT = { + "appinfo": { + "appid": 0x6969 + } +} diff --git a/src/appinfo/tests/appinfo_mocks/app_content.vdf b/src/appinfo/tests/appinfo_mocks/app_content.vdf new file mode 100644 index 0000000..ff8cf08 Binary files /dev/null and b/src/appinfo/tests/appinfo_mocks/app_content.vdf differ diff --git a/src/appinfo/tests/appinfo_mocks/app_header.vdf b/src/appinfo/tests/appinfo_mocks/app_header.vdf new file mode 100644 index 0000000..ed99b9b Binary files /dev/null and b/src/appinfo/tests/appinfo_mocks/app_header.vdf differ diff --git a/src/appinfo/tests/appinfo_mocks/real_appinfo_slice.vdf b/src/appinfo/tests/appinfo_mocks/real_appinfo_slice.vdf new file mode 100644 index 0000000..f43b2c8 Binary files /dev/null and b/src/appinfo/tests/appinfo_mocks/real_appinfo_slice.vdf differ diff --git a/src/appinfo/tests/appinfo_mocks/single_app.vdf b/src/appinfo/tests/appinfo_mocks/single_app.vdf new file mode 100644 index 0000000..bdd5727 Binary files /dev/null and b/src/appinfo/tests/appinfo_mocks/single_app.vdf differ diff --git a/src/appinfo/tests/test_decoder.py b/src/appinfo/tests/test_decoder.py new file mode 100644 index 0000000..5021ab8 --- /dev/null +++ b/src/appinfo/tests/test_decoder.py @@ -0,0 +1,50 @@ +from ..decoder import AppinfoDecoder, AppinfoDecodeError +import struct +import pytest +import os +from . import APPINFO_MOCK_DIR, TEST_APP_CONTENT, TEST_APP_HEADER + +TEST_VDF_BAD_MAGIC = 0x6969 +TEST_VDF_MAGIC_NUMBER = 0x07564428 +TEST_VDF_UNIVERSE = 0x01 + + +def test_compatible_versions(): + version = struct.pack("<2I", TEST_VDF_MAGIC_NUMBER, TEST_VDF_UNIVERSE) + # means no error is raised + assert AppinfoDecoder(version)._validate_vdf_version() == None + with pytest.raises(AppinfoDecodeError): + version = struct.pack("<2I", TEST_VDF_BAD_MAGIC, TEST_VDF_UNIVERSE) + AppinfoDecoder(version)._validate_vdf_version() + +def test_read_app_header(): + with open(f"{APPINFO_MOCK_DIR}/app_header.vdf", "rb") as f: + content = f.read() + print(content) + result = AppinfoDecoder(content)._read_app_header() + print(result["checksum_text"]) + print(TEST_APP_HEADER["checksum_text"]) + assert result == TEST_APP_HEADER + +def test_read_app_content(): + with open(f"{APPINFO_MOCK_DIR}/app_content.vdf", "rb") as f: + app_content = f.read() + result = AppinfoDecoder(app_content)._read_app_content() + assert result == TEST_APP_CONTENT + +def test_read_app(): + with open(f"{APPINFO_MOCK_DIR}/single_app.vdf", "rb") as f: + content = f.read() + result = AppinfoDecoder(content)._read_app() + assert result["header"] == TEST_APP_HEADER + assert result["content"] == TEST_APP_CONTENT + +def test_read_real_appinfo(): + with open(f"{APPINFO_MOCK_DIR}/real_appinfo_slice.vdf", "rb") as f: + content = f.read() + result = AppinfoDecoder(content).decode() + assert len(result["apps"]) == 4 + assert result["apps"][5]["header"]["appid"] == 5 + assert result["apps"][7]["header"]["appid"] == 7 + assert result["apps"][8]["header"]["appid"] == 8 + assert result["apps"][10]["header"]["appid"] == 10 diff --git a/src/appinfo/tests/test_encoder.py b/src/appinfo/tests/test_encoder.py new file mode 100644 index 0000000..3da358e --- /dev/null +++ b/src/appinfo/tests/test_encoder.py @@ -0,0 +1,41 @@ +from .. import AppinfoEncoder, AppinfoDecoder +from . import APPINFO_MOCK_DIR, TEST_APP_CONTENT, TEST_APP_HEADER + + +def test_encode_header(): + with open(f"{APPINFO_MOCK_DIR}/app_header.vdf", "rb") as f: + result = AppinfoEncoder()._encode_header(TEST_APP_HEADER) + assert result == f.read() + +def test_encode_app_content(): + with open(f"{APPINFO_MOCK_DIR}/app_content.vdf", "rb") as f: + result = AppinfoEncoder()._encode_app_content(TEST_APP_CONTENT) + assert result == f.read() + +def test_encode_app(): + with open(f"{APPINFO_MOCK_DIR}/single_app.vdf", "rb") as f: + app = { + "header": TEST_APP_HEADER, + "content": TEST_APP_CONTENT, + } + result = AppinfoEncoder()._encode_app(app) + assert result == f.read() + +def test_encode_real_appinfo(): + with open(f"{APPINFO_MOCK_DIR}/real_appinfo_slice.vdf", "rb") as f: + contents = f.read() + decoded = AppinfoDecoder(contents).decode() + encoded = AppinfoEncoder(decoded).encode() + assert encoded == contents + +def test_header_update(): + expected_size = 0x63 + app = { + "header": TEST_APP_HEADER, + "content": {'appinfo': {'appid': 5, 'public_only': 1}}, + } + result = AppinfoEncoder()._encode_app(app) + updated_header = AppinfoDecoder(result)._read_app_header() + assert updated_header["size"] == 0x63 + assert updated_header["checksum_text"] == b'\x87\xfaCg\x85\x80\r\xb4\x90Im\xdc}\xb4\x81\xeeQ\x8b\x825' + assert updated_header["checksum_binary"] == b'\x85\x1a1#u\xf0E\x9a,\x93\xe2\x8a7\xd1XT\xa1\x82\xb9\x89' diff --git a/src/config.py b/src/config.py index be59103..e00b7cb 100644 --- a/src/config.py +++ b/src/config.py @@ -1,5 +1,5 @@ # A Metadata Editor for Steam Applications -# Copyright (C) 2023 Tomás Ralph +# Copyright (C) 2024 Tomás Ralph # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/src/gui/img/Delete.png b/src/gui/img/Delete.png deleted file mode 100644 index e76d0c3..0000000 Binary files a/src/gui/img/Delete.png and /dev/null differ diff --git a/src/gui/img/DownArrow.png b/src/gui/img/DownArrow.png deleted file mode 100644 index cffb3ae..0000000 Binary files a/src/gui/img/DownArrow.png and /dev/null differ diff --git a/src/gui/img/UpArrow.png b/src/gui/img/UpArrow.png deleted file mode 100644 index e116861..0000000 Binary files a/src/gui/img/UpArrow.png and /dev/null differ diff --git a/src/gui/main_window.py b/src/gui/main_window.py deleted file mode 100644 index 440470a..0000000 --- a/src/gui/main_window.py +++ /dev/null @@ -1,1229 +0,0 @@ -# A Metadata Editor for Steam Applications -# Copyright (C) 2023 Tomás Ralph -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os -import json -from copy import deepcopy -from datetime import datetime -from json import JSONDecodeError - -import tkinter as tk -from tkinter import filedialog, messagebox -from tkinter.ttk import Treeview, Style - -from config import config -from appinfo import Appinfo - -from gui.widgets import ( - Frame, - Button, - DeleteButton, - LabelFrame, - Label, - Entry, - Checkbutton, - Scrollbar, - ScrollableFrame, -) - - -class MainWindow: - def __init__(self): - self.modifiedApps = [] - silent = config.silent - export = config.export - self.vdf_path = os.path.join( - config.STEAM_PATH, "appcache", "appinfo.vdf" - ) - - if export is not None: - self.modifiedApps.extend(export) - self.load_modifications() - self.appinfo = Appinfo( - self.vdf_path, True, apps=self.modifiedApps - ) - - for app in self.modifiedApps: - self.save_original_data(app) - - self.write_modifications() - - if silent: - self.load_modifications() - self.appinfo = Appinfo( - self.vdf_path, True, apps=self.modifiedApps - ) - - for app in self.modifiedApps: - self.appinfo.parsedAppInfo[app]["sections"] = self.jsonData[ - str(app) - ]["modified"] - - self.write_data_to_appinfo(notice=False) - - if not silent and export is None: - self.create_main_window() - - for app in self.modifiedApps: - self.appinfo.parsedAppInfo[app]["sections"] = self.jsonData[ - str(app) - ]["modified"] - - def create_main_window(self): - # Define main window - self.window = tk.Tk() - self.window.title("Steam Metadata Editor") - self.window.resizable(width=False, height=False) - self.window.config(padx=10, pady=10, bg=config.BG) - - # Hide to show loading window - self.window.withdraw() - loadingWindow = LoadingWindow(self.window) - - # Load appinfo - self.appinfo = Appinfo(self.vdf_path) - - # Button images - self.upArrowImage = tk.PhotoImage(file=f"{config.IMG_PATH}/UpArrow.png") - self.downArrowImage = tk.PhotoImage(file=f"{config.IMG_PATH}/DownArrow.png") - self.deleteImage = tk.PhotoImage(file=f"{config.IMG_PATH}/Delete.png") - - # Treeview style - self.treeviewStyle = Style() - self.treeviewStyle.configure( - "Treeview", - font=(config.FONT, 9), - background=config.ENTRY_BG, - foreground=config.ENTRY_FG, - fieldbackground=config.ENTRY_BG, - rowheight=20, - relief=config.ENTRY_RELIEF, - ) - self.treeviewStyle.configure( - "Treeview.Heading", - font=(config.FONT, 10), - background=config.BG, - foreground=config.FG, - relief=config.ENTRY_RELIEF, - ) - - # String vars - self.idVar = tk.StringVar() - self.nameVar = tk.StringVar() - self.sortAsVar = tk.StringVar() - self.developerVar = tk.StringVar() - self.publisherVar = tk.StringVar() - self.ogRelease1Var = tk.StringVar() - self.ogRelease2Var = tk.StringVar() - self.ogRelease3Var = tk.StringVar() - self.steamRelease1Var = tk.StringVar() - self.steamRelease2Var = tk.StringVar() - self.steamRelease3Var = tk.StringVar() - self.searchBarVar = tk.StringVar() - - # Layout setup - self.leftFrame = LabelFrame( - self.window, padx=10, pady=10, text="Search:" - ) - self.rightContainerFrame = Frame(self.window, padx=10, pady=10) - # Label specific - self.rightIdFrame = Frame(self.rightContainerFrame) - self.rightNameFrame = Frame(self.rightContainerFrame) - self.rightSortAsFrame = Frame(self.rightContainerFrame) - self.rightDeveloperFrame = Frame(self.rightContainerFrame) - self.rightPublisherFrame = Frame(self.rightContainerFrame) - self.rightOgReleaseFrame = Frame(self.rightContainerFrame) - self.rightSteamReleaseFrame = Frame(self.rightContainerFrame) - self.buttonsFrame = Frame(self.rightContainerFrame) - - # Widgets (left side) - self.searchBar = Entry( - self.leftFrame, - textvariable=self.searchBarVar, - ) - self.searchBarVar.trace_add( - "write", lambda _a, _b, _c: self.locate_app_in_list() - ) - self.searchBar.focus() - - self.appListScrollbar = Scrollbar(self.leftFrame) - - self.appList = Treeview(self.leftFrame, columns=("Type", "Mod", "ID")) - self.appList.heading("#0", text="App Name") - self.appList.heading("Type", text="Type") - self.appList.heading("Mod", text="Modified") - self.appList.heading("ID", text="ID") - self.appList.column("#0", width=300, stretch=False) - self.appList.column("Type", width=50, minwidth=20) - self.appList.column("Mod", width=55, minwidth=20) - self.appList.column("ID", width=80, minwidth=80) - self.appList.bind("<>", self.fetch_app_data) - - # Widgets (right side) - self.idLabel = Label(self.rightIdFrame, text="ID:") - self.idEntry = Entry( - self.rightIdFrame, - width=40, - textvariable=self.idVar, - state="readonly", - ) - - self.nameLabel = Label(self.rightNameFrame, text="Name:") - self.nameEntry = Entry( - self.rightNameFrame, - width=40, - textvariable=self.nameVar, - ) - - self.sortAsLabel = Label(self.rightSortAsFrame, text="Sort As:") - self.sortAsEntry = Entry( - self.rightSortAsFrame, - width=40, - textvariable=self.sortAsVar, - ) - - self.developerLabel = Label( - self.rightDeveloperFrame, - text="Developer:", - ) - self.developerEntry = Entry( - self.rightDeveloperFrame, - width=40, - textvariable=self.developerVar, - ) - - self.publisherLabel = Label( - self.rightPublisherFrame, - text="Publisher:", - ) - self.publisherEntry = Entry( - self.rightPublisherFrame, - width=40, - textvariable=self.publisherVar, - ) - - self.ogReleaseLabel = Label( - self.rightOgReleaseFrame, - text="Original Release Date:", - ) - self.ogReleaseEntry1 = Entry( - self.rightOgReleaseFrame, - width=4, - textvariable=self.ogRelease1Var, - ) - self.ogReleaseEntry2 = Entry( - self.rightOgReleaseFrame, - width=2, - textvariable=self.ogRelease2Var, - ) - self.ogReleaseEntry3 = Entry( - self.rightOgReleaseFrame, - width=2, - textvariable=self.ogRelease3Var, - ) - - self.steamReleaseLabel = Label( - self.rightSteamReleaseFrame, - text="Steam Release Date:", - ) - self.steamReleaseEntry1 = Entry( - self.rightSteamReleaseFrame, - width=4, - textvariable=self.steamRelease1Var, - ) - self.steamReleaseEntry2 = Entry( - self.rightSteamReleaseFrame, - width=2, - textvariable=self.steamRelease2Var, - ) - self.steamReleaseEntry3 = Entry( - self.rightSteamReleaseFrame, - width=2, - textvariable=self.steamRelease3Var, - ) - - self.launchMenuButton = Button( - self.buttonsFrame, - text="Edit launch menu", - command=self.create_launch_menu_window, - ) - self.revertAppButton = Button( - self.buttonsFrame, - text="Revert App", - command=lambda: self.revert_app(self.idVar.get()), - ) - self.saveButton = Button( - self.buttonsFrame, - text="Save", - command=self.write_data_to_appinfo, - ) - - # Pack widgets (left side) - self.searchBar.pack(side="top", fill="both", pady=(0, 10)) - self.appListScrollbar.pack(side="right", fill="both") - self.appList.pack(side="top", fill="both") - - # Pack widgets (right side) - self.idLabel.pack(side="left") - self.idEntry.pack(side="right") - self.nameLabel.pack(side="left") - self.nameEntry.pack(side="right") - self.sortAsLabel.pack(side="left") - self.sortAsEntry.pack(side="right") - self.developerLabel.pack(side="left") - self.developerEntry.pack(side="right") - self.publisherLabel.pack(side="left") - self.publisherEntry.pack(side="right") - - self.ogReleaseLabel.pack(side="left") - self.ogReleaseEntry3.pack(side="right", padx=(10, 0)) - self.ogReleaseEntry2.pack(side="right", padx=10) - self.ogReleaseEntry1.pack(side="right", padx=(0, 10)) - - self.steamReleaseLabel.pack(side="left") - self.steamReleaseEntry3.pack(side="right", padx=(10, 0)) - self.steamReleaseEntry2.pack(side="right", padx=10) - self.steamReleaseEntry1.pack(side="right", padx=(0, 10)) - - self.launchMenuButton.pack(side="left") - self.revertAppButton.pack(side="left") - self.saveButton.pack(side="right") - - # Frames - self.leftFrame.pack(side="left", fill="both") - self.rightContainerFrame.pack(side="right", fill="both") - - self.rightIdFrame.pack( - side="top", fill="both", pady=(0, config.ENTRY_PADDING) - ) - self.rightNameFrame.pack(side="top", fill="both", pady=config.ENTRY_PADDING) - self.rightSortAsFrame.pack(side="top", fill="both", pady=config.ENTRY_PADDING) - self.rightDeveloperFrame.pack( - side="top", fill="both", pady=config.ENTRY_PADDING - ) - self.rightPublisherFrame.pack( - side="top", fill="both", pady=config.ENTRY_PADDING - ) - self.rightOgReleaseFrame.pack( - side="top", fill="both", pady=config.ENTRY_PADDING - ) - self.rightSteamReleaseFrame.pack( - side="top", fill="both", pady=(config.ENTRY_PADDING, 0) - ) - self.buttonsFrame.pack(side="bottom", fill="both") - - # Extra config - self.appList.config(yscrollcommand=self.appListScrollbar.set) - self.appListScrollbar.config(command=self.appList.yview) - - self.load_modifications() - self.mark_installed_games() - self.populate_app_list() - - # Destroy loading window and show main one - # after appinfo finishes loading - loadingWindow.destroy() - self.window.deiconify() - - # Center window - self.window.update() - self.window.update_idletasks() - self.center_window(self.window) - - def mark_installed_games(self): - lbryPath = os.path.join(config.STEAM_PATH, "steamapps", "libraryfolders.vdf") - with open(lbryPath, "r") as libraries: - contents = libraries.read() - libraries = config.PATH_REGEX.findall(contents) - apps = [int(x) for x in config.APP_REGEX.findall(contents)] - - for library in libraries: - for app in apps: - install_dir = self.get_data_from_section( - app, "config", "installdir" - ) - install_path = os.path.join( - library, "steamapps", "common", install_dir - ) - if not os.path.exists(install_path): - continue - self.appinfo.parsedAppInfo[app]["installed"] = True - self.appinfo.parsedAppInfo[app][ - "install_path" - ] = install_path - - def write_modifications(self): - with open(f"{config.CONFIG_PATH}/modifications.json", "w") as mod: - for app in self.modifiedApps: - self.jsonData[str(app)][ - "modified" - ] = self.appinfo.parsedAppInfo[app]["sections"] - json.dump(self.jsonData, mod, indent=2) - - def save_original_data(self, appID): - appData = deepcopy(self.appinfo.parsedAppInfo[appID]["sections"]) - self.jsonData[str(appID)] = {} - self.jsonData[str(appID)]["original"] = appData - - def load_modifications(self): - try: - with open(f"{config.CONFIG_PATH}/modifications.json", "r") as mod: - self.jsonData = json.load(mod) - for app in self.jsonData: - app = int(app) - if app not in self.modifiedApps: - self.modifiedApps.append(app) - except (FileNotFoundError, JSONDecodeError): - self.jsonData = {} - - def get_data_from_section(self, appID, *sections, error=""): - data = self.appinfo.parsedAppInfo[appID]["sections"]["appinfo"] - for section in sections: - try: - data = data[section] - except KeyError: - return error - - return data - - # Given a var, it removes the callback, sets the value - # and reassigns the callback - def set_var_no_callback(self, var, value, callback): - try: - callbackId = var.trace_vinfo()[0][1] - var.trace_remove("write", callbackId) - except IndexError: - pass - - var.set(value) - var.trace_add("write", callback) - - def set_data_from_section(self, appID, value, *sections): - appID = int(appID) - - if appID not in self.modifiedApps: - self.save_original_data(appID) - self.modifiedApps.append(appID) - - data = self.appinfo.parsedAppInfo[appID]["sections"]["appinfo"] - # Access all but the last element - for section in sections[0:len(sections) - 1]: - try: - data = data[section] - except KeyError: - data[section] = {} - data = data[section] - - data[sections[-1]] = value - - def get_unix_time(self, year, month, day): - return int(datetime(year, month, day).timestamp()) - - def set_timestamps(self, stampId): - appID = int(self.idVar.get()) - - def validate_date_format(year, month, day): - if len(str(year)) > 4 or year < 1970: - return False - try: - if datetime(year, month, day) > datetime.today(): - return False - except ValueError: - return False - - return True - - if stampId == "original": - - try: - year = int(self.ogRelease1Var.get()) - month = int(self.ogRelease2Var.get()) - day = int(self.ogRelease3Var.get()) - # This happens when the field is empty - except ValueError: - return - - if validate_date_format(year, month, day): - - appOgReleaseDate = self.get_unix_time(year, month, day) - self.set_data_from_section( - appID, appOgReleaseDate, "common", "original_release_date" - ) - elif stampId == "steam": - - try: - year = int(self.steamRelease1Var.get()) - month = int(self.steamRelease2Var.get()) - day = int(self.steamRelease3Var.get()) - # This happens when the field is empty - except ValueError: - return - - if validate_date_format(year, month, day): - - appSteamReleaseDate = self.get_unix_time(year, month, day) - self.set_data_from_section( - appID, appSteamReleaseDate, "common", "steam_release_date" - ) - - def write_data_to_appinfo(self, notice=True): - self.write_modifications() - - for appId in self.modifiedApps: - self.appinfo.update_app(appId) - - self.appinfo.write_data() - - if notice: - messagebox.showinfo( - title="Success!", - message="Your changes " + "have been successfully applied!", - ) - - def revert_app(self, appId): - appId = int(appId) - - if appId in self.modifiedApps: - if messagebox.askyesno( - title="Revert Game", - message="Are you " - + "sure you want to revert this game? All your " - + "modifications will be erased, this cannot be undone.", - ): - - # Fetch original data and replace it - originalData = deepcopy(self.jsonData[str(appId)]["original"]) - self.appinfo.parsedAppInfo[appId]["sections"] = originalData - - # Delete app from modified apps - # to not save it in the json again - if appId in self.modifiedApps: - self.modifiedApps.remove(appId) - - # Delete data from json - del self.jsonData[str(appId)] - self.write_modifications() - - self.appinfo.update_app(appId) - self.appinfo.write_data() - - # Update app list - self.appList.delete(*self.appList.get_children()) - self.populate_app_list() - - def fetch_app_data(self, _event): - # Data from list - currentItem = self.appList.focus() - currentItemData = self.appList.item(currentItem) - appID = currentItemData["values"][-1] - # Fetched data - appName = self.get_data_from_section(appID, "common", "name") - appSortAs = self.get_data_from_section(appID, "common", "sortas") - appDeveloper = self.get_data_from_section( - appID, "extended", "developer" - ) - appPublisher = self.get_data_from_section( - appID, "extended", "publisher" - ) - appSteamReleaseDate = self.get_data_from_section( - appID, "common", "steam_release_date" - ) - appOgReleaseDate = self.get_data_from_section( - appID, "common", "original_release_date" - ) - - if not appSteamReleaseDate: - appSteamReleaseDate = 0 - if not appOgReleaseDate: - appOgReleaseDate = appSteamReleaseDate - if not appSortAs: - appSortAs = appName - - appSteamReleaseDate = datetime.fromtimestamp(appSteamReleaseDate) - appOgReleaseDate = datetime.fromtimestamp(appOgReleaseDate) - - self.idVar.set(appID) - - self.set_var_no_callback( - self.nameVar, - appName, - lambda _a, _b, _c: ( - self.set_data_from_section( - int(self.idVar.get()), self.nameVar.get(), "common", "name" - ), - self.sortAsVar.set(self.nameVar.get()), - ), - ) - - self.set_var_no_callback( - self.sortAsVar, - appSortAs, - lambda _a, _b, _c: self.set_data_from_section( - int(self.idVar.get()), self.sortAsVar.get(), "common", "sortas" - ), - ) - - self.set_var_no_callback( - self.developerVar, - appDeveloper, - lambda _a, _b, _c: ( - self.set_data_from_section( - int(self.idVar.get()), - self.developerVar.get(), - "extended", - "developer", - ), - self.set_data_from_section( - int(self.idVar.get()), - self.developerVar.get(), - "common", - "associations", - "0", - "name", - ), - ), - ) - - self.set_var_no_callback( - self.publisherVar, - appPublisher, - lambda _a, _b, _c: ( - self.set_data_from_section( - int(self.idVar.get()), - self.publisherVar.get(), - "extended", - "publisher", - ), - self.set_data_from_section( - int(self.idVar.get()), - self.publisherVar.get(), - "common", - "associations", - "1", - "name", - ), - ), - ) - - self.set_var_no_callback( - self.ogRelease1Var, - f"{appOgReleaseDate:%Y}", - lambda _a, _b, _c: self.set_timestamps("original"), - ) - self.set_var_no_callback( - self.ogRelease2Var, - f"{appOgReleaseDate:%m}", - lambda _a, _b, _c: self.set_timestamps("original"), - ) - self.set_var_no_callback( - self.ogRelease3Var, - f"{appOgReleaseDate:%d}", - lambda _a, _b, _c: self.set_timestamps("original"), - ) - - self.set_var_no_callback( - self.steamRelease1Var, - f"{appSteamReleaseDate:%Y}", - lambda _a, _b, _c: self.set_timestamps("steam"), - ) - self.set_var_no_callback( - self.steamRelease2Var, - f"{appSteamReleaseDate:%m}", - lambda _a, _b, _c: self.set_timestamps("steam"), - ) - self.set_var_no_callback( - self.steamRelease3Var, - f"{appSteamReleaseDate:%d}", - lambda _a, _b, _c: self.set_timestamps("steam"), - ) - - def insert_app_in_list(self, app): - self.appList.insert( - parent="", - index="end", - text=app[0], - values=(app[1], app[2], app[3]), - ) - - def locate_app_in_list(self): - query = self.searchBar.get().lower() - - # Clear list to fill it with results - self.appList.delete(*self.appList.get_children()) - - if query: - for app in self.appData: - if query in app[0].lower(): - self.insert_app_in_list(app) - else: - # Update app list - self.populate_app_list() - - def center_window(self, window): - screenWidth = window.winfo_screenwidth() - screenHeight = window.winfo_screenheight() - windowWidth = window.winfo_reqwidth() - windowHeight = window.winfo_reqheight() - - windowX = (screenWidth / 2) - (windowWidth / 2) - windowY = (screenHeight / 2) - (windowHeight / 2) - - window.geometry( - f"{windowWidth}x{windowHeight}+" + f"{int(windowX)}+{int(windowY)}" - ) - - def ask_to_create_launch_option(self, appID): - self.launchMenuWindow.withdraw() - answer = messagebox.askyesno( - "No Launch Options", - "This app has no launch options. Do you wish to create one?", - ) - if answer: - self.add_launch_option(appID) - self.launchMenuWindow.deiconify() - else: - self.launchMenuWindow.destroy() - - def move_launch_option(self, appID, optionNumber, direction): - launchOption = self.get_data_from_section( - appID, "config", "launch", optionNumber - ) - - if direction == "up": - newOptionNumber = str(int(optionNumber) - 1) - # Check if the location exists - nextLaunchOption = self.get_data_from_section( - appID, "config", "launch", newOptionNumber - ) - - if nextLaunchOption is not False: - self.set_data_from_section( - appID, nextLaunchOption, "config", "launch", optionNumber - ) - self.set_data_from_section( - appID, launchOption, "config", "launch", newOptionNumber - ) - else: - return - self.update_launch_menu_window(appID) - elif direction == "down": - newOptionNumber = str(int(optionNumber) + 1) - # Check if the location exists - prevLaunchOption = self.get_data_from_section( - appID, "config", "launch", newOptionNumber - ) - - if prevLaunchOption is not False: - self.set_data_from_section( - appID, prevLaunchOption, "config", "launch", optionNumber - ) - self.set_data_from_section( - appID, launchOption, "config", "launch", newOptionNumber - ) - else: - return - self.update_launch_menu_window(appID) - else: - return - - def delete_launch_option(self, appID, optionNumber): - launchOptions = self.get_data_from_section(appID, "config", "launch") - - found = False - keys = list(launchOptions.keys()) - for launchOption in keys: - if not found: - if launchOption == optionNumber: - found = True - else: - newOptionNumber = str(int(launchOption) - 1) - self.set_data_from_section( - appID, - launchOptions[launchOption], - "config", - "launch", - newOptionNumber, - ) - del launchOptions[keys[-1]] - self.update_launch_menu_window(appID) - - def add_launch_option(self, appID): - launchOptions = self.get_data_from_section(appID, "config", "launch") - newEntryNumber = str(len(launchOptions)) - self.set_data_from_section( - appID, {}, "config", "launch", newEntryNumber - ) - - self.update_launch_menu_window(appID) - - def split_directory(self, directory): - allparts = [] - while True: - parts = os.path.split(directory) - if parts[0] == directory: - allparts.insert(0, parts[0]) - break - else: - directory = parts[0] - allparts.insert(0, parts[1]) - return allparts - - def calculate_parent_folders(self, executablePath, steamDir): - # Splits all folders in the path into strings - steamDir = self.split_directory(steamDir) - execDir = self.split_directory(executablePath) - - if config.CURRENT_OS == "Windows": - del steamDir[0] - del execDir[0] - - while "" in steamDir: - steamDir.remove("") - while "" in execDir: - execDir.remove("") - - parentFolders = None - - for index, folder in enumerate(steamDir): - # Count how many folders are needed to reach the earliest common - # parent folder - if index == len(execDir) or folder != execDir[index]: - parentFolders = len(steamDir) - index - break - - if parentFolders is not None: - return "../" * parentFolders + "/".join(execDir[index:]) - elif execDir[index:] == steamDir[index]: - return "" - else: - return "/".join(execDir[index + 1:]) - - def generate_launch_option_string( - self, appID, execVar, wkngDirVar, pathType - ): - install_path = self.appinfo.parsedAppInfo[appID]["install_path"] - - if pathType == "exe": - exePath = filedialog.askopenfilename( - parent=self.launchMenuWindow, initialdir=install_path - ) - if not exePath: - return - - exePath = self.calculate_parent_folders( - exePath, install_path - ) - - wkngDirPath = os.path.split(exePath)[0] - - if config.CURRENT_OS == "Windows": - exePath = exePath.replace("/", "\\") - wkngDirPath = wkngDirPath.replace("/", "\\") - execVar.set(exePath) - wkngDirVar.set(wkngDirPath) - - elif pathType == "wkngDir": - wkngDirPath = filedialog.askdirectory( - parent=self.launchMenuWindow, initialdir=install_path - ) - if not wkngDirPath: - return - - wkngDirPath = self.calculate_parent_folders( - wkngDirPath, install_path - ) - - if config.CURRENT_OS == "Windows": - wkngDirPath = wkngDirPath.replace("/", "\\") - wkngDirVar.set(wkngDirPath) - - def write_os_list(self, appID, winVar, macVar, linVar, launchOption): - oslist = [] - if winVar.get(): - oslist.append("windows") - if macVar.get(): - oslist.append("macos") - if linVar.get(): - oslist.append("linux") - - oslist = ",".join(oslist) - - self.set_data_from_section( - appID, oslist, "config", "launch", launchOption, "config", "oslist" - ) - - def create_launch_option( - self, - frame, - appID, - number, - description, - executable, - wkngDir, - arguments, - platforms, - ): - - # Frames - padding = 20 - mainFrame = LabelFrame( - frame, - padx=padding, - pady=padding, - text=number, - ) - descFrame = Frame(mainFrame, padx=padding) - execFrame = Frame(mainFrame, padx=padding) - wkngDirFrame = Frame(mainFrame, padx=padding) - argFrame = Frame(mainFrame, padx=padding) - platformFrame = Frame(mainFrame, padx=padding) - buttonsFrame = Frame(mainFrame, padx=padding) - - # String vars - descVar = tk.StringVar() - wkngDirVar = tk.StringVar() - execVar = tk.StringVar() - argVar = tk.StringVar() - - winVar = tk.BooleanVar() - linVar = tk.BooleanVar() - macVar = tk.BooleanVar() - - # Widgets - descLabel = Label(descFrame, text="Description:") - descEntry = Entry( - descFrame, - textvariable=descVar, - width=60, - ) - - execLabel = Label(execFrame, text="Executable:") - execEntry = Entry( - execFrame, - textvariable=execVar, - width=55, - state="readonly", - ) - execButton = Button( - execFrame, - text="...", - command=lambda: self.generate_launch_option_string( - appID, execVar, wkngDirVar, "exe" - ), - ) - - wkngDirLabel = Label(wkngDirFrame, text="Working Directory:") - wkngDirEntry = Entry( - wkngDirFrame, - textvariable=wkngDirVar, - width=55, - state="readonly", - ) - wkngDirButton = Button( - wkngDirFrame, - text="...", - command=lambda: self.generate_launch_option_string( - appID, execVar, wkngDirVar, "wkngDir" - ), - ) - - argLabel = Label(argFrame, text="Launch Arguments:") - argEntry = Entry( - argFrame, - textvariable=argVar, - width=60, - ) - - # Platform checkbuttons - winCheck = Checkbutton( - platformFrame, - text="Windows", - variable=winVar, - ) - linCheck = Checkbutton( - platformFrame, - text="Linux", - variable=linVar, - ) - macCheck = Checkbutton( - platformFrame, - text="Mac", - variable=macVar, - ) - - deleteButton = DeleteButton( - buttonsFrame, - image=self.deleteImage, - command=lambda: self.delete_launch_option(appID, number), - ) - upButton = Button( - buttonsFrame, - image=self.upArrowImage, - command=lambda: self.move_launch_option(appID, number, "up"), - ) - downButton = Button( - buttonsFrame, - image=self.downArrowImage, - command=lambda: self.move_launch_option(appID, number, "down"), - ) - - # Pack widgets - descLabel.pack(side="left", fill="both") - descEntry.pack(side="right", fill="both") - - wkngDirLabel.pack(side="left", fill="both") - wkngDirButton.pack(side="right", fill="both") - wkngDirEntry.pack(side="right", fill="both") - - execLabel.pack(side="left", fill="both") - execButton.pack(side="right", fill="both") - execEntry.pack(side="right", fill="both") - - argLabel.pack(side="left", fill="both") - argEntry.pack(side="right", fill="both") - - winCheck.pack(side="left", fill="both") - linCheck.pack(side="left", fill="both") - macCheck.pack(side="left", fill="both") - - deleteButton.pack(side="right") - downButton.pack(side="right") - upButton.pack(side="right") - - # Pack frames - mainFrame.pack(expand=True) - descFrame.pack(side="top", fill="both", pady=(padding, 0)) - execFrame.pack(side="top", fill="both") - wkngDirFrame.pack(side="top", fill="both") - argFrame.pack(side="top", fill="both") - platformFrame.pack(side="top") - buttonsFrame.pack(side="top", fill="both", pady=(0, padding)) - - # Insert data - for platform in platforms.split(","): - if platform == "windows": - winVar.set(True) - elif platform == "linux": - linVar.set(True) - elif platform == "macos": - macVar.set(True) - - winVar.trace_add( - "write", - lambda _a, _b, _c: self.write_os_list( - appID, winVar, macVar, linVar, number - ), - ) - linVar.trace_add( - "write", - lambda _a, _b, _c: self.write_os_list( - appID, winVar, macVar, linVar, number - ), - ) - macVar.trace_add( - "write", - lambda _a, _b, _c: self.write_os_list( - appID, winVar, macVar, linVar, number - ), - ) - - self.set_var_no_callback( - descVar, - description, - lambda _a, _b, _c: self.set_data_from_section( - appID, descVar.get(), "config", "launch", number, "description" - ), - ) - - self.set_var_no_callback( - wkngDirVar, - wkngDir, - lambda _a, _b, _c: self.set_data_from_section( - appID, - wkngDirVar.get(), - "config", - "launch", - number, - "workingdir", - ), - ) - - self.set_var_no_callback( - execVar, - executable, - lambda _a, _b, _c: self.set_data_from_section( - appID, execVar.get(), "config", "launch", number, "executable" - ), - ) - - self.set_var_no_callback( - argVar, - arguments, - lambda _a, _b, _c: self.set_data_from_section( - appID, argVar.get(), "config", "launch", number, "arguments" - ), - ) - - # Update to return correct values - mainFrame.update() - return [ - mainFrame.winfo_reqwidth() + padding, - mainFrame.winfo_reqheight(), - padding, - ] - - def update_launch_menu_window(self, appID): - # Clear frame and store current scroll position - scrollbarPosition = 0 - for widget in self.scrollFrame.scrollableFrame.winfo_children(): - scrollbarPosition = self.scrollFrame.scrollbar.get()[0] - widget.destroy() - - # Read launch options and gather data - appLaunchOptions = self.get_data_from_section( - appID, "config", "launch" - ) - if not appLaunchOptions: - self.ask_to_create_launch_option(appID) - return - - frameCount = 0 - for launchOption in appLaunchOptions.keys(): - description = self.get_data_from_section( - appID, "config", "launch", launchOption, "description" - ) - executable = self.get_data_from_section( - appID, "config", "launch", launchOption, "executable" - ) - wkngDir = self.get_data_from_section( - appID, "config", "launch", launchOption, "workingdir" - ) - arguments = self.get_data_from_section( - appID, "config", "launch", launchOption, "arguments" - ) - platforms = self.get_data_from_section( - appID, - "config", - "launch", - launchOption, - "config", - "oslist", - error="Not specified", - ) - - geometry = self.create_launch_option( - self.scrollFrame.scrollableFrame, - appID, - launchOption, - description, - executable, - wkngDir, - arguments, - platforms, - ) - - if frameCount < 2: - frameCount += 1 - - # Add widgets for adding new entries - newEntryFrame = Frame(self.scrollFrame.scrollableFrame) - newEntryButton = Button( - newEntryFrame, - text="Add New Entry", - command=lambda: self.add_launch_option(appID), - ) - - padding = 10 - newEntryButton.pack(side="top", anchor="n") - newEntryFrame.pack(side="bottom", pady=(padding, 0)) - - # Offsets size of scrollbar and - # takes padding (geometry[2]) into account - self.scrollFrame.scrollbar.update() - geometry[0] += self.scrollFrame.scrollbar.winfo_reqwidth() - geometry[1] *= frameCount - geometry[1] += geometry[2] * 2 - geometry[1] += newEntryFrame.winfo_reqheight() + padding - - # Resizes window depending on the number of launch options - self.scrollFrame.canvas.config(width=geometry[0], height=geometry[1]) - self.scrollFrame.canvas.yview_moveto(scrollbarPosition) - - def create_launch_menu_window(self): - appName = self.nameVar.get() - appID = int(self.idVar.get()) - - self.launchMenuWindow = tk.Toplevel(self.window) - self.launchMenuWindow.resizable(False, False) - self.launchMenuWindow.title( - f"Launch Menu Editor for {appName} ({appID})" - ) - - self.scrollFrame = ScrollableFrame(self.launchMenuWindow) - self.scrollFrame.scrollableFrame.config(bg=config.BG, padx=20, pady=20) - - self.update_launch_menu_window(appID) - - self.scrollFrame.pack() - - self.launchMenuWindow.update() - self.center_window(self.launchMenuWindow) - # Prevent the use of the main window while this one exists - self.launchMenuWindow.grab_set() - self.launchMenuWindow.mainloop() - - def populate_app_list(self): - # Get all applications found in appinfo.vdf - keys = list(self.appinfo.parsedAppInfo.keys()) - - self.appData = [] - - for app in keys[2:]: - appID = app - appType = self.get_data_from_section(appID, "common", "type") - modified = appID in self.modifiedApps - appName = self.get_data_from_section(appID, "common", "name") - if not appName or not appType: - pass - else: - self.appData.append([str(appName), appType, modified, appID]) - - # Sort case-insensitive - self.appData.sort(key=lambda x: str(x[0]).lower()) - - for app in self.appData: - self.insert_app_in_list(app) - - -class LoadingWindow(tk.Toplevel): - def __init__(self, parent): - tk.Toplevel.__init__(self, parent) - self.title("Steam Metadata Editor (Loading)") - self.resizable(width=False, height=False) - self.config(bg=config.BG) - - self.loadingLabel = Label(self, text="Loading appinfo.vdf...") - - self.loadingLabel.pack(padx=30, pady=30) - - # Wait for widgets to actually load in - # else they are sometimes not displayed, - - # TODO: Figure out why this hangs some systems - # self.loadingLabel.wait_visibility() - # self.wait_visibility() - - self.update() diff --git a/src/gui/widgets.py b/src/gui/widgets.py deleted file mode 100644 index a6244eb..0000000 --- a/src/gui/widgets.py +++ /dev/null @@ -1,140 +0,0 @@ -# A Metadata Editor for Steam Applications -# Copyright (C) 2023 Tomás Ralph -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import tkinter as tk - -from config import config - - -class Frame(tk.Frame): - def __init__(self, *args, **kwargs): - super().__init__(*args, bg=config.BG, **kwargs) - - -class LabelFrame(tk.LabelFrame): - def __init__(self, *args, **kwargs): - super().__init__(*args, bg=config.BG, fg=config.FG, font=config.FONT, **kwargs) - - -class Label(tk.Label): - def __init__(self, *args, **kwargs): - super().__init__(*args, bg=config.BG, fg=config.FG, font=config.FONT, **kwargs) - - -class Scrollbar(tk.Scrollbar): - def __init__(self, *args, **kwargs): - super().__init__( - *args, - bd=0, - relief=config.ENTRY_RELIEF, - bg=config.ENTRY_BG, - activebackground=config.ENTRY_BG, - **kwargs - ) - - -class Checkbutton(tk.Checkbutton): - def __init__(self, *args, **kwargs): - super().__init__( - *args, - bg=config.BG, - fg=config.ENTRY_FG, - relief=config.ENTRY_RELIEF, - selectcolor=config.ENTRY_BG, - **kwargs - ) - - -class Entry(tk.Entry): - def __init__(self, *args, **kwargs): - super().__init__( - *args, - font=config.FONT, - readonlybackground=config.ENTRY_BG, - relief=config.ENTRY_RELIEF, - bg=config.ENTRY_BG, - fg=config.ENTRY_FG, - **kwargs - ) - - -class Button(tk.Button): - def __init__(self, *args, **kwargs): - super().__init__( - *args, - activebackground=config.BTTN_ACTIVE_BG, - activeforeground=config.BTTN_ACTIVE_FG, - relief=config.BTTN_RELIEF, - font=(config.BTTN_FONT, config.BTTN_FONT_SIZE), - bg=config.BTTN_BG, - fg=config.BTTN_FG, - **kwargs - ) - - -class DeleteButton(tk.Button): - def __init__(self, *args, **kwargs): - super().__init__( - *args, - activebackground=config.DLT_BTTN_ACTIVE_BG, - activeforeground=config.BTTN_ACTIVE_FG, - relief=config.BTTN_RELIEF, - font=(config.BTTN_FONT, config.BTTN_FONT_SIZE), - bg=config.DLT_BTTN_BG, - fg=config.BTTN_FG, - **kwargs - ) - - -class ScrollableFrame(Frame): - def __init__(self, container, *args, **kwargs): - super().__init__(container, *args, **kwargs) - self.canvas = tk.Canvas(self) - self.scrollbar = Scrollbar( - self, - orient="vertical", - command=self.canvas.yview, - ) - self.scrollableFrame = Frame(self.canvas) - - self.scrollableFrame.bind( - "", - lambda _e: self.canvas.configure( - scrollregion=self.canvas.bbox("all") - ), - ) - - self.canvas.create_window( - (0, 0), window=self.scrollableFrame, anchor="nw" - ) - - self.canvas.configure(yscrollcommand=self.scrollbar.set) - # X11 - self.canvas.bind_all("", self.scroll_canvas) - self.canvas.bind_all("", self.scroll_canvas) - # Everything else - self.canvas.bind_all("", self.scroll_canvas) - - self.canvas.pack(side="left", fill="both", expand=True) - self.scrollbar.pack(side="right", fill="y") - - def scroll_canvas(self, event): - if event.num == 5 or event.delta < 0: - direction = 1 - elif event.num == 4 or event.delta > 0: - direction = -1 - - self.canvas.yview_scroll(direction, "units") diff --git a/src/main.py b/src/main.py index 0f1289f..fb004dc 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # # A Metadata Editor for Steam Applications -# Copyright (C) 2023 Tomás Ralph +# Copyright (C) 2024 Tomás Ralph # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -23,24 +23,18 @@ # # ################################## -from tkinter import messagebox - -from config import config -from gui.main_window import MainWindow -from appinfo import IncompatibleVDFError +from models.appinfo import AppinfoFile +from models.steam_libraries import SteamLibraries +from view import View +import sys +import json def main(): - try: - main_window = MainWindow() - if not config.silent and config.export is None: - main_window.window.mainloop() - except IncompatibleVDFError as e: - messagebox.showerror( - title="Invalid VDF Version", - message=f"VDF version {e.vdf_version:#08x} is not supported.", - ) - + steam_libraries = SteamLibraries("/home/tralph3/.local/share/Steam/") + model = AppinfoFile("/home/tralph3/.local/share/Steam/appcache/appinfo.vdf", steam_libraries) + view = View(model, application_id="com.github.Metadata-Editor") + view.run(sys.argv) if __name__ == "__main__": main() diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/appinfo.py b/src/models/appinfo.py new file mode 100644 index 0000000..4700eb5 --- /dev/null +++ b/src/models/appinfo.py @@ -0,0 +1,77 @@ +from typing import Optional +import appinfo +from .steam_libraries import AppID + +class AppinfoFile: + def __init__(self, file_path, steam_libraries): + self._file_path = file_path + self._appinfo = appinfo.loads(file_path) + self._steam_libraries = steam_libraries + + def write(self): + with open(self._file_path, "wb") as f: + appinfo.dump(self._appinfo, f) + + def get_all_apps(self) -> dict: + return self._appinfo["apps"] + + def get_app_count(self) -> int: + return len(self._appinfo["apps"]) + + def set_app_name(self, appid: AppID, name: str): + self._appinfo["apps"][appid]["content"]["appinfo"]["common"]["name"] = name + + def get_app_name(self, appid: AppID) -> Optional[str]: + try: + return self._appinfo["apps"][appid]["content"]["appinfo"]["common"]["name"] + except KeyError: + return None + + def set_app_sortas(self, appid: AppID, sortas: str): + self._appinfo["apps"][appid]["content"]["appinfo"]["common"]["sortas"] = sortas + + def get_app_sortas(self, appid: AppID) -> Optional[str]: + try: + return self._appinfo["apps"][appid]["content"]["appinfo"]["common"]["sortas"] + except KeyError: + return None + + def get_app_type(self, appid: AppID) -> Optional[str]: + try: + return self._appinfo["apps"][appid]["content"]["appinfo"]["common"]["type"] + except KeyError: + return None + + def set_app_steam_release_date(self, appid: AppID, release_date: int): + self._appinfo["apps"][appid]["content"]["appinfo"]["common"]["steam_release_date"] = release_date + + def get_app_steam_release_date(self, appid: AppID) -> Optional[int]: + try: + return self._appinfo["apps"][appid]["content"]["appinfo"]["common"]["steam_release_date"] + except KeyError: + return None + + def set_app_original_release_date(self, appid: AppID, release_date: int): + self._appinfo["apps"][appid]["content"]["appinfo"]["common"]["original_release_date"] = release_date + + def get_app_original_release_date(self, appid: AppID) -> Optional[int]: + try: + return self._appinfo["apps"][appid]["content"]["appinfo"]["common"]["original_release_date"] + except KeyError: + return None + + def set_app_launch_menu(self, appid: AppID, launch_menu: dict): + self._appinfo["apps"][appid]["content"]["appinfo"].setdefault("config", {}) + self._appinfo["apps"][appid]["content"]["appinfo"]["config"]["launch"] = launch_menu + + def get_app_launch_menu(self, appid: AppID) -> Optional[dict]: + try: + return self._appinfo["apps"][appid]["content"]["appinfo"]["config"]["launch"] + except KeyError: + return None + + def is_app_installed(self, appid: AppID) -> bool: + return self._steam_libraries.is_app_installed(appid) + + def get_app_install_path(self, appid: AppID) -> str: + return self._steam_libraries.get_app_install_path(appid) diff --git a/src/models/steam_libraries.py b/src/models/steam_libraries.py new file mode 100644 index 0000000..b470b1c --- /dev/null +++ b/src/models/steam_libraries.py @@ -0,0 +1,54 @@ +import textvdf +from os import path + + +AppID = int +LibID = str + + +class AppNotInstalledError(Exception): + pass + +class NoManifestError(Exception): + pass + +class SteamLibraries: + def __init__(self, steam_install_path): + self._steam_install_path = steam_install_path + libraryfolders_path = path.join(self._steam_install_path, "steamapps", "libraryfolders.vdf") + self._libraryfolders = textvdf.loads(libraryfolders_path)["libraryfolders"] + self._appinfo: dict[AppID, dict] = {} + self._populate_appinfo() + + def _populate_appinfo(self): + for library in self._libraryfolders: + for appid in self._libraryfolders[library]["apps"]: + appid = int(appid) + self._appinfo.setdefault(appid, {}) + manifest = self._get_app_manifest(appid, library) + installdir = manifest["AppState"]["installdir"] + librarypath = self._get_library_path(library) + installpath = path.join(librarypath, "steamapps", "common", installdir) + self._appinfo[appid]["installpath"] = installpath + for depot in manifest["AppState"].get("InstalledDepots", {}): + depot = int(depot) + self._appinfo.setdefault(depot, {}) + self._appinfo[depot]["installpath"] = installpath + + def _get_library_path(self, libraryid: LibID) -> str: + return self._libraryfolders[libraryid]["path"] + + def _get_app_manifest(self, appid: AppID, libraryid: LibID) -> dict: + library_path = self._get_library_path(libraryid) + manifest_path = path.join(library_path, "steamapps", f"appmanifest_{appid}.acf") + if not path.exists(manifest_path): + raise NoManifestError(f"App '{appid}' has no manifest file at '{manifest_path}'") + return textvdf.loads(manifest_path) + + def is_app_installed(self, appid: AppID) -> bool: + return appid in self._appinfo + + def get_app_install_path(self, appid: AppID) -> str: + if not self.is_app_installed(appid): + raise AppNotInstalledError(f"App '{appid}' is not installed") + return self._appinfo[appid]["installpath"] diff --git a/src/textvdf/__init__.py b/src/textvdf/__init__.py new file mode 100644 index 0000000..1bb6407 --- /dev/null +++ b/src/textvdf/__init__.py @@ -0,0 +1,25 @@ +from .decoder import TextVdfDecoder, TextVdfDecodeError +from .encoder import TextVdfEncoder + + +__version__ = '0.1' +__all__ = [ + 'dump', 'dumps', 'load', 'loads', + 'TextVdfDecoder', 'TextVdfDecodeError', 'TextVdfEncoder', +] + +__author__ = 'Tomás Ralph ' + + +def dump(obj: dict, fp) -> None: + fp.write(dumps(obj)) + +def dumps(obj: dict) -> bytearray: + return TextVdfEncoder(obj).encode() + +def load(fp) -> dict: + return TextVdfDecoder(fp.read()).decode() + +def loads(file_path: str) -> dict: + with open(file_path, "r") as f: + return load(f) diff --git a/src/textvdf/decoder.py b/src/textvdf/decoder.py new file mode 100644 index 0000000..10bfdc5 --- /dev/null +++ b/src/textvdf/decoder.py @@ -0,0 +1,50 @@ +import re + + +REGEX_TAB = re.compile(r'^\t+', flags=re.MULTILINE) +REGEX_PAIR = re.compile(r'"(.*?)"\t\t"(.*?)"') +REGEX_DICTIONARY = re.compile(r'"([^"]+?)"{') + + +class TextVdfDecodeError(Exception): + pass + + +class TextVdfDecoder: + def __init__(self, contents: str): + self.contents = contents + self.pointer = 0 + + def decode(self) -> dict: + self._sanitize_input() + return self._parse_contents() + + def _sanitize_input(self): + self.contents = REGEX_TAB.sub('', self.contents) + self.contents = self.contents.replace("\n", "") + self.contents += "\n" + + def _parse_contents(self) -> dict: + results = {} + while self.contents[self.pointer] not in "}\n": + if self._standing_on_dictionary(): + dict_match = REGEX_DICTIONARY.search(self.contents, pos=self.pointer) + key = dict_match.group(1) + self.pointer = dict_match.end() + results[key] = self._parse_contents() + else: + key, val = self._parse_pair() + results[key] = val + self.pointer += 1 + return results + + def _parse_pair(self) -> (str, str): + match = REGEX_PAIR.search(self.contents, pos=self.pointer) + if not match or len(match.groups()) != 2: + raise TextVdfDecodeError("Unexpected key/value format") + self.pointer = match.end() + return (match.group(1), match.group(2)) + + def _standing_on_dictionary(self) -> bool: + dict_match = REGEX_DICTIONARY.search(self.contents, pos=self.pointer) + return dict_match and dict_match.start() == self.pointer diff --git a/src/textvdf/encoder.py b/src/textvdf/encoder.py new file mode 100644 index 0000000..3740db7 --- /dev/null +++ b/src/textvdf/encoder.py @@ -0,0 +1,24 @@ +import json + + +class TextVdfEncoder: + def __init__(self, obj: dict=None): + self._obj = obj + self._indent = 0 + + def encode(self, obj: dict=None) -> str: + result = "" + tabs = "\t" * self._indent + if obj is None: obj = self._obj + for key in obj: + if isinstance(obj[key], dict): + self._indent += 1 + value = self.encode(obj[key]) + key = key.replace("\\", "\\\\") + result += f'{tabs}"{key}"\n{tabs}{"{\n"}{value}{tabs}{"}\n"}' + self._indent -= 1 + else: + value = str(obj[key]).replace("\\", "\\\\") + key = key.replace("\\", "\\\\") + result += f'{tabs}"{key}"\t\t"{value}"\n' + return result diff --git a/src/textvdf/tests/__init__.py b/src/textvdf/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/textvdf/tests/common.py b/src/textvdf/tests/common.py new file mode 100644 index 0000000..580ab72 --- /dev/null +++ b/src/textvdf/tests/common.py @@ -0,0 +1,23 @@ +import os + + +TEXT_VDF_MOCK_DIR = os.path.join(os.path.dirname(__file__), "vdf_mocks") + +decoded_dict = { + "libraryfolders": { + "0": { + "path": "/some/path", + "apps": { + "this": "test" + }, + "new": "key", + }, + "1": { + "path": "/other/path??", + "apps": { + "42": "007" + }, + "empty_dict": {} + }, + }, +} diff --git a/src/textvdf/tests/test_decoder.py b/src/textvdf/tests/test_decoder.py new file mode 100644 index 0000000..9d6fcfa --- /dev/null +++ b/src/textvdf/tests/test_decoder.py @@ -0,0 +1,13 @@ +from ..decoder import TextVdfDecoder, TextVdfDecodeError +from .common import decoded_dict, TEXT_VDF_MOCK_DIR +import pytest + + +def test_input_sanitization(): + decoder = TextVdfDecoder("\"key\"\n{\n\t\"0\"\t\t\"smth\"\n}\n") + decoder._sanitize_input() + assert decoder.contents == "\"key\"{\"0\"\t\t\"smth\"}\n" + +def test_decoding(): + with open(f"{TEXT_VDF_MOCK_DIR}/libraryfolders.vdf", "r") as f: + assert TextVdfDecoder(f.read()).decode() == decoded_dict diff --git a/src/textvdf/tests/test_encoder.py b/src/textvdf/tests/test_encoder.py new file mode 100644 index 0000000..120f382 --- /dev/null +++ b/src/textvdf/tests/test_encoder.py @@ -0,0 +1,8 @@ +from .. import TextVdfEncoder, TextVdfDecoder +from .common import decoded_dict, TEXT_VDF_MOCK_DIR +import pytest + + +def test_encoder(): + with open(f"{TEXT_VDF_MOCK_DIR}/libraryfolders.vdf", "r") as f: + assert f.read() == TextVdfEncoder(decoded_dict).encode() diff --git a/src/textvdf/tests/vdf_mocks/libraryfolders.vdf b/src/textvdf/tests/vdf_mocks/libraryfolders.vdf new file mode 100644 index 0000000..63ce520 --- /dev/null +++ b/src/textvdf/tests/vdf_mocks/libraryfolders.vdf @@ -0,0 +1,23 @@ +"libraryfolders" +{ + "0" + { + "path" "/some/path" + "apps" + { + "this" "test" + } + "new" "key" + } + "1" + { + "path" "/other/path??" + "apps" + { + "42" "007" + } + "empty_dict" + { + } + } +} diff --git a/src/view/__init__.py b/src/view/__init__.py new file mode 100644 index 0000000..8160337 --- /dev/null +++ b/src/view/__init__.py @@ -0,0 +1,22 @@ +import gi + +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') + +from gi.repository import Adw, Gtk, Gdk +from .main_window import MainWindow + + +class View(Adw.Application): + def __init__(self, model, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model = model + css_provider = Gtk.CssProvider() + css_provider.load_from_path('src/view/style.css') + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + self.connect('activate', self.on_activate) + + def on_activate(self, app): + self.win = MainWindow(self.model, application=app) + self.win.present() diff --git a/src/view/events/__init__.py b/src/view/events/__init__.py new file mode 100644 index 0000000..7dc8d51 --- /dev/null +++ b/src/view/events/__init__.py @@ -0,0 +1,19 @@ +from enum import Enum, auto +from typing import Callable + +class Event(Enum): + LOAD_APP = auto() + SAVE_CHANGES = auto() + DELETE_LAUNCH_ENTRY = auto() + +_event_subscriptors: dict[Event, [Callable]] = {} + + +def event_connect(event: Event, func: Callable): + if event not in _event_subscriptors: + _event_subscriptors[event] = [] + _event_subscriptors[event].append(func) + +def event_emit(event: Event, *args, **kwargs): + for func in _event_subscriptors[event]: + func(*args, **kwargs) diff --git a/src/view/main_window/__init__.py b/src/view/main_window/__init__.py new file mode 100644 index 0000000..cf50020 --- /dev/null +++ b/src/view/main_window/__init__.py @@ -0,0 +1,131 @@ +# A Metadata Editor for Steam Applications +# Copyright (C) 2024 Tomás Ralph +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from gi.repository import Gtk, Adw +from .app_list import AppColumnView +from view.objects import App +from .details_box import DetailsBox +from view.events import Event, event_emit +from .util import clean_string +from .launch_menu import LaunchMenu + +MARGIN = 50 + +class MainWindow(Gtk.ApplicationWindow): + def __init__(self, model, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model = model + self._has_loaded_app = False + self._configure_window() + self._make_widgets() + self._configure_widgets() + + def _configure_window(self): + self.set_default_size(1400, 500) + self.set_title("Steam Metadata Editor") + self.set_css_classes(["main_window"]) + + def _make_widgets(self): + left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._search_entry = Gtk.SearchEntry(placeholder_text="Search by name...") + scrolled_window = Gtk.ScrolledWindow() + self._app_column_view = AppColumnView() + scrolled_frame = Gtk.Frame() + self._main_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + margin_end=MARGIN, + margin_start=MARGIN, + margin_top=MARGIN, + margin_bottom=MARGIN, + spacing=30, + ) + details_box = DetailsBox(self.model) + launch_menu = LaunchMenu(self.model) + tool_bar = Adw.ToolbarView() + action_bar = Gtk.ActionBar() + no_app_status_page = Adw.StatusPage() + no_app_status_page.set_title("No App Selected") + no_app_status_page.set_description("Start by selecting an app from the list on the left.") + no_app_status_page.set_icon_name("dialog-information") + + self._save_button = Gtk.Button(label="Save") + self._quit_button = Gtk.Button(label="Quit without saving") + + self._save_button.set_css_classes(["button", "main_button"]) + self._quit_button.set_css_classes(["button"]) + + scrolled_window.set_child(self._app_column_view) + scrolled_window.set_hexpand(True) + scrolled_frame.set_child(scrolled_window) + + left_box.append(self._search_entry) + left_box.append(scrolled_frame) + left_box.set_spacing(10) + + self._right_box.append(details_box) + self._right_box.append(launch_menu) + self._right_box.set_spacing(30) + + self._main_box.append(left_box) + self._main_box.append(no_app_status_page) + self._main_box.set_homogeneous(True) + + action_bar.pack_end(self._save_button) + action_bar.pack_start(self._quit_button) + tool_bar.add_bottom_bar(action_bar) + tool_bar.set_content(self._main_box) + self.set_child(tool_bar) + + def _configure_widgets(self): + self._search_entry.connect("search-changed", self._on_search_changed) + self._app_column_view.add_apps(self._make_app_list()) + self._app_column_view.connect('activate', self._change_current_app) + self._save_button.connect("clicked", self._save_changes) + self._quit_button.connect("clicked", lambda *_: self.destroy()) + + def _change_current_app(self, column_view, index): + if not self._has_loaded_app: + self._has_loaded_app = True + self._main_box.remove(self._main_box.get_last_child()) + self._main_box.append(self._right_box) + app: App = column_view.get_model().get_item(index) + event_emit(Event.SAVE_CHANGES) + event_emit(Event.LOAD_APP, app) + + def _make_app_list(self) -> [App]: + app_list = [] + for appid in self.model.get_all_apps(): + name = self.model.get_app_name(appid) + if name == None: continue + type = self.model.get_app_type(appid) + installed = self.model.is_app_installed(appid) + modified = False + app_list.append(App( + name or "", + appid or -1, + type or "", + installed, + modified)) + app_list.sort(key=lambda app: clean_string(app.name)) + return app_list + + def _on_search_changed(self, entry: Gtk.SearchEntry): + search_query = entry.get_text() + self._app_column_view.filter_apps_by_name(search_query) + + def _save_changes(self, _=None): + event_emit(Event.SAVE_CHANGES) + self.model.write() diff --git a/src/view/main_window/app_list.py b/src/view/main_window/app_list.py new file mode 100644 index 0000000..f52b7d8 --- /dev/null +++ b/src/view/main_window/app_list.py @@ -0,0 +1,124 @@ +import gi + +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Gio, Pango +from view.objects import App +from .util import clean_string + + +class AppColumnView(Gtk.ColumnView): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.search_query = "" + + self.list_store = Gio.ListStore() + + filter_model = Gtk.CustomFilter().new(match_func=self._filter_match) + self.filter = Gtk.FilterListModel() + self.filter.set_filter(filter_model) + self.filter.set_model(self.list_store) + + selection_model = Gtk.SingleSelection().new(model=self.filter) + self.set_model(selection_model) + self.set_vexpand(True) + self.set_hexpand(False) + self._make_columns() + + self.set_property("single-click-activate", True) + + def add_app(self, app: App): + self.list_store.append(app) + + def add_apps(self, apps: [App]): + for app in apps: + self.add_app(app) + + def filter_apps_by_name(self, search_term: str): + self.search_query = clean_string(search_term) + self.filter.get_filter().changed(Gtk.FilterChange.DIFFERENT) + + def _filter_match(self, app: App): + return self.search_query in clean_string(app.name) + + def _make_columns(self): + name_column = Gtk.ColumnViewColumn() + name_column.set_title("Name") + name_factory = Gtk.SignalListItemFactory() + name_factory.connect("setup", self._setup_name_factory) + name_factory.connect("bind", self._bind_name_factory) + name_column.set_factory(name_factory) + name_column.set_expand(True) + self.append_column(name_column) + + installed_column = Gtk.ColumnViewColumn() + installed_column.set_title("Installed") + installed_factory = Gtk.SignalListItemFactory() + installed_factory.connect("setup", self._setup_installed_factory) + installed_factory.connect("bind", self._bind_installed_factory) + installed_column.set_factory(installed_factory) + self.append_column(installed_column) + + modified_column = Gtk.ColumnViewColumn() + modified_column.set_title("Modified") + modified_factory = Gtk.SignalListItemFactory() + modified_factory.connect("setup", self._setup_modified_factory) + modified_factory.connect("bind", self._bind_modified_factory) + modified_column.set_factory(modified_factory) + self.append_column(modified_column) + + def _setup_name_factory(self, _fact, item): + column_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.START) + subtitle_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START) + + name_label = Gtk.Label() + name_label.set_halign(Gtk.Align.START) + name_label.set_ellipsize(Pango.EllipsizeMode.END) + name_label.set_css_classes(['app_title']) + + id_label = Gtk.Label() + id_label.set_halign(Gtk.Align.START) + id_label.set_width_chars(10) + id_label.set_xalign(0) + + type_label = Gtk.Label() + type_label.set_halign(Gtk.Align.START) + type_label.set_ellipsize(Pango.EllipsizeMode.END) + + column_container.append(name_label) + column_container.append(subtitle_container) + + subtitle_container.append(id_label) + subtitle_container.append(type_label) + subtitle_container.set_css_classes(['app_subtitle']) + + item.set_child(column_container) + + def _bind_name_factory(self, _fact, item): + name_label = item.get_child().get_first_child() + id_label = item.get_child().get_last_child().get_first_child() + type_label = item.get_child().get_last_child().get_last_child() + app = item.get_item() + name_label.set_label(app.name) + id_label.set_label(str(app.id)) + type_label.set_label(app.type.upper()) + + def _setup_installed_factory(self, _fact, item): + checkbutton = Gtk.CheckButton() + checkbutton.set_sensitive(False) + item.set_child(checkbutton) + + def _bind_installed_factory(self, _fact, item): + checkbutton = item.get_child() + app = item.get_item() + checkbutton.set_active(app.installed) + + def _setup_modified_factory(self, _fact, item): + checkbutton = Gtk.CheckButton() + checkbutton.set_sensitive(False) + item.set_child(checkbutton) + + def _bind_modified_factory(self, _fact, item): + checkbutton = item.get_child() + app = item.get_item() + checkbutton.set_active(app.modified) diff --git a/src/view/main_window/details_box.py b/src/view/main_window/details_box.py new file mode 100644 index 0000000..bb3df32 --- /dev/null +++ b/src/view/main_window/details_box.py @@ -0,0 +1,124 @@ +from gi.repository import Gtk, Gdk +from datetime import datetime +from view.objects import App +from view.events import Event, event_connect +from .util import compose_entry_box + +DATE_FORMAT = '%-d of %B, %Y' + +class DetailsBox(Gtk.Box): + def __init__(self, model, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model = model + self._current_app: App = None + self._new_steam_release_date: int = None + self._new_original_release_date: int = None + self._intial_app_values: dict = { + "name": None, + "sortas": None, + "steam_release": None, + "original_release": None, + } + self._make_widgets() + self._connect_signals() + + def _make_widgets(self): + self.set_orientation(Gtk.Orientation.VERTICAL) + self.set_spacing(15) + id_box, self.id_entry = compose_entry_box("App ID", editable=False) + name_box, self.name_entry = compose_entry_box("Name") + sortas_box, self.sortas_entry = compose_entry_box("Sort as", "Unspecified") + steam_release_box, self.steam_release_entry = compose_entry_box( + "Steam release date", "Unspecified", False, "x-office-calendar") + original_release_box, self.original_release_entry = compose_entry_box( + "Original release date", "Unspecified", False, "x-office-calendar") + self.append(id_box) + self.append(name_box) + self.append(sortas_box) + self.append(steam_release_box) + self.append(original_release_box) + + def _connect_signals(self): + self.steam_release_entry.connect("icon-press", self._show_calendar_popover) + self.original_release_entry.connect("icon-press", self._show_calendar_popover) + event_connect(Event.LOAD_APP, self.load_app) + event_connect(Event.SAVE_CHANGES, self._save_current_app) + + def _show_calendar_popover(self, entry, icon_position): + popover = Gtk.Popover() + calendar = Gtk.Calendar() + timestamp = None + if entry == self.steam_release_entry: + timestamp = self._intial_app_values["steam_release"] + elif entry == self.original_release_entry: + timestamp = self._intial_app_values["original_release"] + if timestamp: + timestamp = datetime.fromtimestamp(timestamp) + calendar.set_year(timestamp.year) + calendar.set_month(timestamp.month - 1) + calendar.set_day(timestamp.day) + popover.set_parent(entry) + calendar.connect("day-selected", lambda calendar: self._set_date_from_unix(entry, calendar.get_date().to_unix())) + popover.set_child(calendar) + popover.set_position(icon_position) + popover.show() + + def _save_current_app(self): + self._update_app_name() + self._update_app_sortas() + self._update_app_steam_release_date() + self._update_app_original_release_date() + + def _set_current_app(self, app: App): + self._current_app = app + self._intial_app_values = { + "name": self.model.get_app_name(self._current_app.id) or "", + "sortas": self.model.get_app_sortas(self._current_app.id) or "", + "steam_release": self.model.get_app_steam_release_date(self._current_app.id), + "original_release": self.model.get_app_original_release_date(self._current_app.id) + } + self._new_steam_release_date = None + self._new_original_release_date = None + + def _set_entries_by_current_app(self): + self.id_entry.set_text(str(self._current_app.id)) + self.name_entry.set_text(self._intial_app_values["name"]) + self.sortas_entry.set_text(self._intial_app_values["sortas"]) + self._set_date_from_unix(self.steam_release_entry, self._intial_app_values["steam_release"]) + self._set_date_from_unix(self.original_release_entry, self._intial_app_values["original_release"]) + + def _set_date_from_unix(self, entry: Gtk.Entry, timestamp: int): + if not timestamp: + entry.set_text("") + return + date = datetime.fromtimestamp(timestamp) + formatted_date = date.strftime('%-d of %B, %Y') + entry.set_text(formatted_date) + if entry == self.steam_release_entry: + self._new_steam_release_date = timestamp + elif entry == self.original_release_entry: + self._new_original_release_date = timestamp + + def load_app(self, app: App): + self._set_current_app(app) + self._set_entries_by_current_app() + + def _update_app_name(self, _=None): + name_text = self.name_entry.get_text() + if name_text != "" and name_text != self._intial_app_values["name"]: + self.model.set_app_name(self._current_app.id, name_text) + + def _update_app_sortas(self, _=None): + sortas_text = self.sortas_entry.get_text() + if sortas_text != "" and sortas_text != self._intial_app_values["sortas"]: + self.model.set_app_sortas(self._current_app.id, sortas_text) + + def _update_app_steam_release_date(self, _=None): + if self._new_steam_release_date == self._intial_app_values["steam_release"]: + return + self.model.set_app_steam_release_date(self._current_app.id, self._new_steam_release_date) + + def _update_app_original_release_date(self, _=None): + if self._new_original_release_date == self._intial_app_values["original_release"]: + return + self.model.set_app_original_release_date(self._current_app.id, self._new_original_release_date) diff --git a/src/view/main_window/launch_menu.py b/src/view/main_window/launch_menu.py new file mode 100644 index 0000000..e5297b2 --- /dev/null +++ b/src/view/main_window/launch_menu.py @@ -0,0 +1,203 @@ +from gi.repository import Gtk, Adw +from view.objects import App +from view.events import Event, event_connect, event_emit +from .util import compose_entry_box, _make_box + +class LaunchEntry(Gtk.Box): + def __init__(self, model, entry: dict, appid: int, *args, **kwargs): + super().__init__(*args, **kwargs) + self._appid = appid + self._model = model + self._entry = entry + self._make_widgets() + self._configure_widgets() + + def _make_widgets(self): + self.set_orientation(Gtk.Orientation.VERTICAL) + description_box, self._description_entry = compose_entry_box("Description", "Unspecified") + executable_box, self._executable_entry = compose_entry_box("Executable", "Unspecified", False, "folder") + workdir_box, self._workdir_entry = compose_entry_box("Working Directory", "Unspecified", False, "folder") + arguments_box, self._arguments_entry = compose_entry_box("Launch Arguments", "Unspecified") + delete_button = Gtk.Button() + bottom_box = Gtk.Box() + checkbutton_box = Gtk.Box(hexpand=True) + self._windows_checkbutton = Gtk.CheckButton(label="Windows") + self._mac_checkbutton = Gtk.CheckButton(label="Mac") + self._linux_checkbutton = Gtk.CheckButton(label="Linux") + + delete_button.set_icon_name("edit-delete") + delete_button.connect("clicked", lambda *_: self._delete_self()) + delete_button.set_css_classes(["button", "delete_button"]) + + checkbutton_box.append(self._windows_checkbutton) + checkbutton_box.append(self._mac_checkbutton) + checkbutton_box.append(self._linux_checkbutton) + bottom_box.append(checkbutton_box) + bottom_box.append(delete_button) + bottom_box.set_hexpand(True) + + self.append(description_box) + self.append(executable_box) + self.append(workdir_box) + self.append(arguments_box) + self.append(bottom_box) + + def _configure_widgets(self): + self.set_spacing(15) + self._description_entry.set_text(self._entry.get("description", "")) + self._executable_entry.set_text(self._entry.get("executable", "")) + self._workdir_entry.set_text(self._entry.get("workingdir", "")) + arguments = self._entry.get("arguments", "") + if isinstance(arguments, int): arguments = "" + self._arguments_entry.set_text(arguments) + + entry_oslist = self._entry.get("config", {}).get("oslist", "").lower() + self._windows_checkbutton.set_active("windows" in entry_oslist) + self._mac_checkbutton.set_active("macos" in entry_oslist) + self._linux_checkbutton.set_active("linux" in entry_oslist) + + def _delete_self(self): + event_emit(Event.DELETE_LAUNCH_ENTRY, self) + + def _make_oslist_string(self) -> str: + selected_os = [] + if self._windows_checkbutton.get_active(): + selected_os.append("windows") + if self._mac_checkbutton.get_active(): + selected_os.append("macos") + if self._linux_checkbutton.get_active(): + selected_os.append("linux") + return ','.join(selected_os) + + def get_updated_entry(self) -> dict: + if self._description_entry.get_text(): + self._entry["description"] = self._description_entry.get_text() + if self._executable_entry.get_text(): + self._entry["executable"] = self._executable_entry.get_text() + if self._workdir_entry.get_text(): + self._entry["workingdir"] = self._workdir_entry.get_text() + if self._arguments_entry.get_text(): + self._entry["arguments"] = self._arguments_entry.get_text() + oslist = self._make_oslist_string() + if oslist: + self._entry.setdefault("config", {}) + self._entry["config"]["oslist"] = oslist + return self._entry + + +class LaunchMenu(Gtk.Frame): + def __init__(self, model, *args, **kwargs): + super().__init__(*args, **kwargs) + self._model = model + self._current_app: App = None + self._initial_app_launch_options = None + self.set_vexpand(True) + self._make_widgets() + self._connect_signals() + self.set_css_classes(['launch_menu']) + + def _make_widgets(self): + self._scrolled_window = Gtk.ScrolledWindow() + container_box = _make_box() + container_box.set_spacing(30) + container_box.set_margin_bottom(30) + self._entries_box = _make_box() + self._entries_box.set_spacing(0) + self._empty_status_page = Adw.StatusPage() + + add_button = Gtk.Button(label="Add launch entry") + add_button.set_css_classes(["button", "main_button"]) + add_button.connect("clicked", lambda *_: self._add_empty_entry()) + add_button.set_hexpand(False) + add_button.set_halign(Gtk.Align.CENTER) + + status_add_button = Gtk.Button(label="Add launch entry") + status_add_button.set_css_classes(["button", "main_button"]) + status_add_button.connect("clicked", lambda *_: self._add_empty_entry()) + status_add_button.set_hexpand(False) + status_add_button.set_halign(Gtk.Align.CENTER) + + self._empty_status_page.set_title("No entries") + self._empty_status_page.set_description("This app has no launch entries.") + self._empty_status_page.set_icon_name("dialog-information") + self._empty_status_page.set_child(status_add_button) + + container_box.append(self._entries_box) + container_box.append(add_button) + + self._scrolled_window.set_child(container_box) + self._set_child_widget() + + def _set_child_widget(self): + if not self._get_entry_list(): + self.set_child(self._empty_status_page) + else: + self.set_child(self._scrolled_window) + + def _connect_signals(self): + event_connect(Event.LOAD_APP, self._load_app) + event_connect(Event.DELETE_LAUNCH_ENTRY, self._delete_launch_entry) + event_connect(Event.SAVE_CHANGES, self._save_current_app) + + def _delete_launch_entry(self, entry: LaunchEntry): + self._entries_box.remove(entry) + self._recalculate_entry_css_classes() + self._set_child_widget() + + def _make_entries(self): + if not self._current_app: return + app_entries = self._model.get_app_launch_menu(self._current_app.id) + self._initial_app_launch_options = app_entries or {} + if not app_entries: return + for i, entry in enumerate(app_entries): + self._add_launch_entry(app_entries[entry]) + + def _delete_all_entries_from_box(self): + for entry in self._get_entry_list(): + self._entries_box.remove(entry) + + def _add_launch_entry(self, values: dict): + self._entries_box.append(LaunchEntry(self._model, values, self._current_app.id)) + self._recalculate_entry_css_classes() + self._set_child_widget() + + def _recalculate_entry_css_classes(self): + for i, entry in enumerate(self._get_entry_list()): + if i % 2 == 0: + entry.set_css_classes(["launch_entry_even"]) + else: + entry.set_css_classes(["launch_entry_odd"]) + + def _add_empty_entry(self): + self._add_launch_entry({}) + + def _load_app_entries(self): + self._delete_all_entries_from_box() + self._make_entries() + + def _get_entry_list(self) -> [LaunchEntry]: + entries = [] + child = self._entries_box.get_first_child() + while child: + entries.append(child) + child = child.get_next_sibling() + return entries + + def _get_updated_entries(self) -> dict: + results = {} + for i, entry in enumerate(self._get_entry_list()): + updated_entry = entry.get_updated_entry() + if updated_entry: + results[str(i)] = updated_entry + return results + + def _load_app(self, app: App): + self._current_app = app + self._load_app_entries() + self._set_child_widget() + + def _save_current_app(self): + updated_entries = self._get_updated_entries() + if self._initial_app_launch_options is not None \ + and self._initial_app_launch_options != updated_entries: + self._model.set_app_launch_menu(self._current_app.id, updated_entries) diff --git a/src/view/main_window/util.py b/src/view/main_window/util.py new file mode 100644 index 0000000..c48eb45 --- /dev/null +++ b/src/view/main_window/util.py @@ -0,0 +1,32 @@ +from gi.repository import Gtk + + +def compose_entry_box(label: str, placeholder: str=None, editable: bool=True, icon: str=None) -> (Gtk.Box, Gtk.Entry): + box = _make_box() + label = _make_label(label) + entry = _make_entry(placeholder, editable, icon) + box.append(label) + box.append(entry) + return (box, entry) + +def _make_box() -> Gtk.Box: + return Gtk.Box(orientation=Gtk.Orientation.VERTICAL, hexpand=True, spacing=5) + +def _make_label(label: str) -> Gtk.Label: + label = Gtk.Label(label=label.upper(), vexpand=False, halign=Gtk.Align.START) + label.set_css_classes(['entry_title']) + return label + +def _make_entry(placeholder: str=None, editable: bool=True, icon: str=None) -> Gtk.Entry: + entry = Gtk.Entry(vexpand=False, hexpand=True) + if not editable: + entry.set_editable(False) + entry.set_can_focus(False) + if placeholder: + entry.set_placeholder_text(placeholder) + if icon: + entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + return entry + +def clean_string(string: str) -> str: + return ''.join(char for char in string if char.isalnum()).lower() diff --git a/src/utils.py b/src/view/objects.py similarity index 52% rename from src/utils.py rename to src/view/objects.py index d027888..d3e192f 100644 --- a/src/utils.py +++ b/src/view/objects.py @@ -1,5 +1,5 @@ # A Metadata Editor for Steam Applications -# Copyright (C) 2023 Tomás Ralph +# Copyright (C) 2024 Tomás Ralph # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -13,15 +13,19 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from gi.repository import GObject -from tkinter import filedialog, messagebox +class App(GObject.Object): + name=GObject.Property(type=str, default="") + id=GObject.Property(type=int, default=-1) + installed=GObject.Property(type=bool, default=False) + type=GObject.Property(type=str, default="") + modified=GObject.Property(type=bool, default=False) - -def ask_steam_path(): - messagebox.showinfo( - title="Can't locate Steam", - message="Steam couldn't be located in your system, " - + "or there's no \"appinfo.vdf\" file present. " - + "Please point to it's installation directory." - ) - return filedialog.askdirectory() + def __init__(self, name: str, id: int, type: str, installed: bool, modified: bool) -> None: + super().__init__() + self.name = name + self.id = id + self.type = type + self.installed = installed + self.modified = modified diff --git a/src/view/style.css b/src/view/style.css new file mode 100644 index 0000000..3485a93 --- /dev/null +++ b/src/view/style.css @@ -0,0 +1,70 @@ +* { + border-radius: 2px; +} + +.button { + background-color: #3d4450; + color: #fff; + border: none; + outline: none; + padding: 4px 16px; +} + +.button:hover { + background-color: #464d58; +} + +.main_button { + background: linear-gradient(90deg, #06BFFF 0%, #2D73FF 100%); +} + +.main_button:hover { + background: #54a5d4; +} + +.delete_button { + background-color: #C44848; +} + +.delete_button:hover { + background-color: #C46565; +} + +.main_window { + background-color: #181a21; +} + +.app_title { + font-weight: bold; + font-size: 16px; +} + +.app_subtitle { + font-weight: 400; + font-size: 14px; + opacity: 0.6; +} + +.entry_title { + font-weight: 500; + font-size: 12px; + color: rgb(175, 175, 175); +} + +.launch_menu { + padding: 0; + margin: 0; +} + +.launch_entry_even { + background-color: #0e1014; +} + +.launch_entry_odd { + background-color: #262d36; +} + +.launch_entry_even, +.launch_entry_odd { + padding: 18px; +}