From 38b22fb6c798a82ec93343a7d75db8e58e4c812a Mon Sep 17 00:00:00 2001 From: Zexyen Date: Wed, 15 Jul 2026 21:29:10 -0400 Subject: [PATCH 01/10] Initial Linux Port Work --- .gitignore | 2 + CLIGen.spec | 11 +-- Class/openkhmod.py | 8 +- Class/seedSettings.py | 6 +- KH2 Randomizer Debug.spec | 11 +-- KH2 Randomizer Linux.spec | 79 +++++++++++++++++ KH2 Randomizer.spec | 17 +--- List/ObjectiveList.py | 6 +- List/configDict.py | 38 ++++---- List/location/agrabah.py | 6 +- List/location/atlantica.py | 6 +- List/location/beastscastle.py | 6 +- List/location/disneycastle.py | 6 +- List/location/formlevel.py | 4 +- List/location/halloweentown.py | 6 +- List/location/hollowbastion.py | 6 +- List/location/hundredacrewood.py | 6 +- List/location/landofdragons.py | 6 +- List/location/olympuscoliseum.py | 6 +- List/location/portroyal.py | 6 +- List/location/pridelands.py | 6 +- List/location/puzzlereward.py | 6 +- List/location/simulatedtwilighttown.py | 6 +- List/location/spaceparanoids.py | 6 +- List/location/starting.py | 4 +- List/location/synthesis.py | 4 +- List/location/twilighttown.py | 6 +- List/location/worldthatneverwas.py | 6 +- Module/compat.py | 18 ++++ Module/cosmetics.py | 6 +- Module/cosmeticsmods/keyblade.py | 4 +- Module/cosmeticsmods/openkh.py | 12 +-- Module/platformutils.py | 96 +++++++++++++++++++++ Module/zipper.py | 1 + README.md | 35 ++++++++ UI/GithubInfo/releaseInfo.py | 59 +++++++++++-- UI/Submenus/CosmeticsMenu.py | 11 ++- UI/Submenus/CustomVisualsDialogs.py | 4 +- UI/Submenus/DevCreateRecolorDialog.py | 2 +- UI/Submenus/ManageKeybladesDialog.py | 4 +- UI/Submenus/TextureRecolorSettingsDialog.py | 4 +- UI/configui.py | 3 +- UI/worker.py | 21 ++++- cli_requirements.txt | 5 +- linux_main.py | 14 +++ localUI.py | 21 +++-- packaging/linux/AppRun | 4 + packaging/linux/build_appimage.sh | 48 +++++++++++ packaging/linux/kh2randomizer.desktop | 8 ++ requirements.txt | 33 ++++--- updater.py | 15 +++- 51 files changed, 539 insertions(+), 175 deletions(-) create mode 100644 KH2 Randomizer Linux.spec create mode 100644 Module/compat.py create mode 100644 Module/platformutils.py create mode 100644 linux_main.py create mode 100755 packaging/linux/AppRun create mode 100755 packaging/linux/build_appimage.sh create mode 100644 packaging/linux/kh2randomizer.desktop diff --git a/.gitignore b/.gitignore index 8933c302..86499b19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.zip +*.AppImage __pycache__ venv +.venv .vs .vscode .idea/ diff --git a/CLIGen.spec b/CLIGen.spec index 4eb229c6..13fdbc44 100644 --- a/CLIGen.spec +++ b/CLIGen.spec @@ -1,9 +1,6 @@ # -*- mode: python ; coding: utf-8 -*- -block_cipher = None - - import os, glob, shutil, importlib, khbr for root, dirs, files in os.walk(DISTPATH): @@ -38,7 +35,7 @@ def external_data_recursive(paths): if dest_dirname == "": dest_dirname = "." else: - dest_dirname = dest_dirname.split("site-packages\\")[1] + dest_dirname = dest_dirname.split("site-packages" + os.sep)[1] data_entry = (filename, dest_dirname) datas.append(data_entry) @@ -62,19 +59,15 @@ updater_analysis = Analysis( hooksconfig={}, runtime_hooks=[], excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, noarchive=False, ) -pyz = PYZ(updater_analysis.pure, updater_analysis.zipped_data, cipher=block_cipher) +pyz = PYZ(updater_analysis.pure) updater_exe = EXE( pyz, updater_analysis.scripts, updater_analysis.binaries, - updater_analysis.zipfiles, updater_analysis.datas, [], name='cli_gen', diff --git a/Class/openkhmod.py b/Class/openkhmod.py index 83bff3db..04a222e9 100644 --- a/Class/openkhmod.py +++ b/Class/openkhmod.py @@ -1,6 +1,6 @@ import re from copy import deepcopy -from enum import Enum +from enum import StrEnum from pathlib import PurePath, Path from typing import Any, Optional, Iterator, Union from zipfile import ZipFile @@ -53,7 +53,7 @@ class ModYmlSyntaxException(ModYmlException): pass -class AssetMethod(str, Enum): +class AssetMethod(StrEnum): # https://github.com/OpenKH/OpenKh/blob/8f967bd412a9e7104a5124ae2688815307ba2472/OpenKh.Patcher/Metadata.cs#L96-L107 AREADATASCRIPT = "areadatascript" BDSCRIPT = "bdscript" @@ -67,12 +67,12 @@ class AssetMethod(str, Enum): SYNTHPATCH = "synthpatch" -class AssetPlatform(str, Enum): +class AssetPlatform(StrEnum): PC = "pc" PS2 = "ps2" -class BinarcMethod(str, Enum): +class BinarcMethod(StrEnum): # Taken from OpenKH docs originally, but found others in use in practice. # It's not entirely clear whether these are even meant to be a separate entity from the AssetMethod. AREADATASCRIPT = "areadatascript" diff --git a/Class/seedSettings.py b/Class/seedSettings.py index 0d406851..ff41183a 100644 --- a/Class/seedSettings.py +++ b/Class/seedSettings.py @@ -4,10 +4,12 @@ import string import textwrap from dataclasses import dataclass -from enum import Enum +from enum import StrEnum from typing import Callable, Any from bitstring import BitArray + +from Module import compat as _compat # noqa: F401 - must precede kh2fmbr import from kh2fmbr.randomizer import Randomizer as khbr from Class import settingkey @@ -55,7 +57,7 @@ def _format_list_for_spoiler(values: list[str]) -> str: return "(none)" -class SettingGroup(str, Enum): +class SettingGroup(StrEnum): """ Serves to provide a rough grouping of settings. Not necessarily meant to designate where things should live in the seed generator UI; rather, meant to give a way for settings to be grouped for things like the spoiler log. diff --git a/KH2 Randomizer Debug.spec b/KH2 Randomizer Debug.spec index a5de96e8..b8e01998 100644 --- a/KH2 Randomizer Debug.spec +++ b/KH2 Randomizer Debug.spec @@ -1,9 +1,6 @@ # -*- mode: python ; coding: utf-8 -*- -block_cipher = None - - import os, glob, khbr khbrpath = os.path.dirname(khbr.__file__) def build_datas_recursive(paths): @@ -31,7 +28,7 @@ def external_data_recursive(paths): if dest_dirname == "": dest_dirname = "." else: - dest_dirname = dest_dirname.split("site-packages\\")[1] + dest_dirname = dest_dirname.split("site-packages" + os.sep)[1] data_entry = (filename, dest_dirname) datas.append(data_entry) @@ -56,12 +53,9 @@ a = Analysis( hooksconfig={}, runtime_hooks=[], excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, noarchive=False, ) -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) +pyz = PYZ(a.pure) @@ -69,7 +63,6 @@ exe = EXE( pyz, a.scripts, a.binaries, - a.zipfiles, a.datas, [], name='KH2 Randomizer DEBUG VERSION', diff --git a/KH2 Randomizer Linux.spec b/KH2 Randomizer Linux.spec new file mode 100644 index 00000000..e4aafd7a --- /dev/null +++ b/KH2 Randomizer Linux.spec @@ -0,0 +1,79 @@ +# -*- mode: python ; coding: utf-8 -*- + +# Linux build: a single onedir app (see packaging/linux/build_appimage.sh, which wraps +# the output in an AppImage). Unlike the Windows build there is no separate updater +# executable; linux_main.py dispatches --updater to the updater UI. + +import glob +import os + +from PyInstaller.utils.hooks import collect_data_files + + +def build_datas_recursive(paths): + datas = [] + + for path in paths: + for filename in glob.iglob(path, recursive=True): + if os.path.isfile(filename): + dest_dirname = os.path.dirname(filename) + if dest_dirname == "": + dest_dirname = "." + + data_entry = (filename, dest_dirname) + datas.append(data_entry) + print(data_entry) + + return datas + + +a = Analysis( + ['linux_main.py'], + pathex=[], + binaries=[], + datas=build_datas_recursive([ + 'UI/**/*.*', + 'UI/*.*', + 'static/**/*.*', + 'static/*.*', + 'presets/*.*', + 'Module/icon.png', + 'extracted_data.zip', + ]) + collect_data_files('kh2fmbr', include_py_files=False), + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='kh2randomizer', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name='kh2randomizer', +) diff --git a/KH2 Randomizer.spec b/KH2 Randomizer.spec index 8bd03e74..05eb4bf7 100644 --- a/KH2 Randomizer.spec +++ b/KH2 Randomizer.spec @@ -1,9 +1,6 @@ # -*- mode: python ; coding: utf-8 -*- -block_cipher = None - - import os, glob, shutil, importlib, kh2fmbr for root, dirs, files in os.walk(DISTPATH): @@ -40,7 +37,7 @@ def external_data_recursive(paths): if dest_dirname == "": dest_dirname = "." else: - dest_dirname = dest_dirname.split("site-packages\\")[1] + dest_dirname = dest_dirname.split("site-packages" + os.sep)[1] data_entry = (filename, dest_dirname) datas.append(data_entry) @@ -63,19 +60,15 @@ updater_analysis = Analysis( hooksconfig={}, runtime_hooks=[], excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, noarchive=False, ) -pyz = PYZ(updater_analysis.pure, updater_analysis.zipped_data, cipher=block_cipher) +pyz = PYZ(updater_analysis.pure) updater_exe = EXE( pyz, updater_analysis.scripts, updater_analysis.binaries, - updater_analysis.zipfiles, updater_analysis.datas, [], name='updater', @@ -112,18 +105,14 @@ a = Analysis( hooksconfig={}, runtime_hooks=[], excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, noarchive=False, ) -rando_pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) +rando_pyz = PYZ(a.pure) exe = EXE( rando_pyz, a.scripts, a.binaries, - a.zipfiles, a.datas, [], name='KH2 Randomizer', diff --git a/List/ObjectiveList.py b/List/ObjectiveList.py index e0650587..df5a2b51 100644 --- a/List/ObjectiveList.py +++ b/List/ObjectiveList.py @@ -1,12 +1,12 @@ from dataclasses import dataclass -from enum import Enum +from enum import Enum, StrEnum -class ObjectiveType(str, Enum): +class ObjectiveType(StrEnum): BOSS = "Boss" WORLDPROGRESS = "WorldProgress" FIGHT = "Fight" -class ObjectiveDifficulty(str, Enum): +class ObjectiveDifficulty(StrEnum): EARLY = "Early" MIDDLE = "Middle" LATE = "Late" diff --git a/List/configDict.py b/List/configDict.py index 45f8a7d1..8b0cfa3b 100644 --- a/List/configDict.py +++ b/List/configDict.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum VANILLA = "vanilla" RANDOMIZE_ONE = "rand1" @@ -7,7 +7,7 @@ RANDOMIZE_CUSTOM_ONLY = "randCustomOnly" -class locationType(str, Enum): +class locationType(StrEnum): LoD = "Land of Dragons" BC = "Beast's Castle" HB = "Hollow Bastion" @@ -45,7 +45,7 @@ class locationType(str, Enum): Creations = "Creations" -class locationCategory(str, Enum): +class locationCategory(StrEnum): CHEST = "Chest" POPUP = "Popup" CREATION = "Creation" @@ -72,7 +72,7 @@ def bonus_categories(): ] -class itemDifficulty(str, Enum): +class itemDifficulty(StrEnum): SUPEREASY = "Super Easy" EASY = "Easy" SLIGHTLY_EASY = "Slightly Easy" @@ -84,7 +84,7 @@ class itemDifficulty(str, Enum): NIGHTMARE = "Nightmare" -class itemBias(str, Enum): +class itemBias(StrEnum): VERY_EARLY = "Very Early" EARLY = "Early" SLIGHTLY_EARLY = "Slightly Early" @@ -96,7 +96,7 @@ class itemBias(str, Enum): NIGHTMARE = "As Late as Possible" -class locationDepth(str, Enum): +class locationDepth(StrEnum): Anywhere = "Anywhere" NonSuperboss = "SecondVisit" # Keep an old naming for compatibility FirstVisit = "FirstVisit" @@ -122,7 +122,7 @@ def location_depth_choices() -> dict[locationDepth, str]: } -class expCurve(str, Enum): +class expCurve(StrEnum): DAWN = "Dawn" MIDDAY = "Midday" DUSK = "Dusk" @@ -132,7 +132,7 @@ def from_name(name: str): return next(c for c in expCurve if c.name == name) -class itemType(str, Enum): +class itemType(StrEnum): PROOF_OF_CONNECTION = "Proof of Connection" PROOF_OF_PEACE = "Proof of Peace" PROOF_OF_NONEXISTENCE = "Proof of Nonexistence" @@ -174,44 +174,44 @@ class itemType(str, Enum): OBJECTIVE="Objective" -class itemRarity(str, Enum): +class itemRarity(StrEnum): COMMON = "Common" UNCOMMON = "Uncommon" RARE = "Rare" MYTHIC = "Mythic" -class SoraLevelOption(str, Enum): +class SoraLevelOption(StrEnum): LEVEL_1 = "Level" LEVEL_50 = "ExcludeFrom50" LEVEL_99 = "ExcludeFrom99" -class AbilityPoolOption(str, Enum): +class AbilityPoolOption(StrEnum): DEFAULT = "default" RANDOMIZE = "randomize" RANDOMIZE_SUPPORT = "randomize support" RANDOMIZE_STACKABLE = "randomize stackable" -class ItemAccessibilityOption(str, Enum): +class ItemAccessibilityOption(StrEnum): ALL = "all" BEATABLE = "beatable" -class SoftlockPreventionOption(str, Enum): +class SoftlockPreventionOption(StrEnum): DEFAULT = "default" REVERSE = "reverse" BOTH = "both" -class DisableFinalOption(str, Enum): +class DisableFinalOption(StrEnum): DEFAULT = "default" NO_ANTIFORM = "no_antiform" NO_FINAL = "no_final" -class BattleLevelOption(str, Enum): +class BattleLevelOption(StrEnum): NORMAL = "Normal" SHUFFLE = "Shuffle" OFFSET = "Offset" @@ -221,7 +221,7 @@ class BattleLevelOption(str, Enum): SPHERE_SCALING = "Scale to Spheres" -class ObjectivePoolOption(str, Enum): +class ObjectivePoolOption(StrEnum): ALL = "All Objectives" BOSSES = "Bosses Only" LASTSTORY = "Last Story Check" @@ -229,14 +229,14 @@ class ObjectivePoolOption(str, Enum): HITLIST = "Spike Hit List" -class LevelUpStatBonus(str, Enum): +class LevelUpStatBonus(StrEnum): STRENGTH = "Strength" MAGIC = "Magic" DEFENSE = "Defense" AP = "AP" -class HintType(str, Enum): +class HintType(StrEnum): DISABLED = "Disabled" JSMARTEE = "JSmartee" SHANANAS = "Shananas" @@ -245,7 +245,7 @@ class HintType(str, Enum): SPOILER = "Spoiler" -class FinalDoorRequirement(str, Enum): +class FinalDoorRequirement(StrEnum): THREE_PROOF = "Three Proofs" OBJECTIVES = "Objectives" EMBLEMS = "Emblems" diff --git a/List/location/agrabah.py b/List/location/agrabah.py index 800dea62..dcb66907 100644 --- a/List/location/agrabah.py +++ b/List/location/agrabah.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType from List.inventory import misc, magic, keyblade, ability, summon @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): AgrabahMapPopup = "Agrabah Map Popup" Agrabah = "Agrabah" AgrabahChests = "Agrabah Chests" @@ -33,7 +33,7 @@ class NodeId(str, Enum): DataLexaeus = "Data Lexaeus" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): AgrabahMap = "Agrabah Map" AgrabahDarkShard = "Agrabah Dark Shard" AgrabahMythrilShard = "Agrabah Mythril Shard" diff --git a/List/location/atlantica.py b/List/location/atlantica.py index ae5c30d6..9747e886 100644 --- a/List/location/atlantica.py +++ b/List/location/atlantica.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType from List.inventory import keyblade, magic @@ -6,13 +6,13 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): AtlanticaTutorial = "Atlantica Tutorial" Ursula = "Ursula" NewDayIsDawning = "New Day is Dawning" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): UnderseaKingdomMap = "Undersea Kingdom Map" MysteriousAbyss = "Mysterious Abyss" MusicalBlizzardElement = "Musical Blizzard Element" diff --git a/List/location/beastscastle.py b/List/location/beastscastle.py index cc7f939b..f32592ad 100644 --- a/List/location/beastscastle.py +++ b/List/location/beastscastle.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType from List.inventory import magic, keyblade, ability, report @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): BeastsCastleCourtyard = "BC Courtyard" BeastsCastleCourtyardChests = "BC Courtyard Chests" BellesRoom = "Belle's Room" @@ -34,7 +34,7 @@ class NodeId(str, Enum): DataXaldin = "Data Xaldin" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): CourtyardApBoost = "BC Courtyard AP Boost" CourtyardHiPotion = "BC Courtyard Hi-Potion" CourtyardMythrilShard = "BC Courtyard Mythril Shard" diff --git a/List/location/disneycastle.py b/List/location/disneycastle.py index bb0a14d3..0ffdf876 100644 --- a/List/location/disneycastle.py +++ b/List/location/disneycastle.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType, itemType from List.inventory import misc, magic, keyblade, ability, form @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): DisneyCastleCourtyard = "DC Courtyard" DisneyCastleCourtyardChests = "DC Courtyard Chests" Library = "Library" @@ -29,7 +29,7 @@ class NodeId(str, Enum): LingeringWill = "Lingering Will" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): CourtyardMythrilShard = "DC Courtyard Mythril Shard" CourtyardStarRecipe = "DC Courtyard Star Recipe" CourtyardApBoost = "DC Courtyard AP Boost" diff --git a/List/location/formlevel.py b/List/location/formlevel.py index f18c06ba..04c6b8d1 100644 --- a/List/location/formlevel.py +++ b/List/location/formlevel.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from typing import Optional from Class.newLocationClass import KH2Location @@ -10,7 +10,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): Valor2 = "Valor Level 2" Valor3 = "Valor Level 3" Valor4 = "Valor Level 4" diff --git a/List/location/halloweentown.py b/List/location/halloweentown.py index 5377388e..31bccb59 100644 --- a/List/location/halloweentown.py +++ b/List/location/halloweentown.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType, itemType from List.inventory import magic, keyblade, ability, misc @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): Graveyard = "Graveyard" GraveyardChests = "Graveyard Chests" FinklesteinsLab = "Finklestein's Lab" @@ -30,7 +30,7 @@ class NodeId(str, Enum): DataVexen = "Data Vexen" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): GraveyardMythrilShard = "Graveyard Mythril Shard" GraveyardSerenityGem = "Graveyard Serenity Gem" FinklesteinsLabHalloweenTownMap = "Finklestein's Lab Halloween Town Map" diff --git a/List/location/hollowbastion.py b/List/location/hollowbastion.py index e71e469a..9a653b48 100644 --- a/List/location/hollowbastion.py +++ b/List/location/hollowbastion.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType, itemType from List.inventory import magic, keyblade, ability, summon, report, storyunlock, form, misc @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): MarketplaceMapPopup = "Marketplace Map Popup" Borough = "Borough" BoroughChests = "Borough Chests" @@ -56,7 +56,7 @@ class NodeId(str, Enum): TransportToRemembrance = "Transport to Remembrance" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): MarketplaceMap = "Marketplace Map" BoroughDriveRecovery = "Borough Drive Recovery" BoroughApBoost = "Borough AP Boost" diff --git a/List/location/hundredacrewood.py b/List/location/hundredacrewood.py index a28a3502..ad2c86c6 100644 --- a/List/location/hundredacrewood.py +++ b/List/location/hundredacrewood.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType from List.inventory import magic, keyblade @@ -6,7 +6,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): PoohsHowse = "Pooh's Howse" PoohsHowseChests = "Pooh's Howse Chests" PigletsHowse = "Piglet's Howse" @@ -22,7 +22,7 @@ class NodeId(str, Enum): StarryHillPopups = "Starry Hill Popups" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): PoohsHowseHundredAcreWoodMap = "Pooh's House 100 Acre Wood Map" PoohsHowseApBoost = "Pooh's House AP Boost" PoohsHowseMythrilStone = "Pooh's House Mythril Stone" diff --git a/List/location/landofdragons.py b/List/location/landofdragons.py index 32a537a4..d8d21cb0 100644 --- a/List/location/landofdragons.py +++ b/List/location/landofdragons.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType from List.inventory import misc, magic, keyblade, ability @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): BambooGrove = "Bamboo Grove" BambooGroveChests = "Bamboo Grove Chests" EncampmentAreaMap = "Encampment Area Map" @@ -29,7 +29,7 @@ class NodeId(str, Enum): DataXigbar = "Data Xigbar" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): BambooGroveDarkShard = "Bamboo Grove Dark Shard" BambooGroveEther = "Bamboo Grove Ether" BambooGroveMythrilShard = "Bamboo Grove Mythril Shard" diff --git a/List/location/olympuscoliseum.py b/List/location/olympuscoliseum.py index 3bd764f3..acbd4c71 100644 --- a/List/location/olympuscoliseum.py +++ b/List/location/olympuscoliseum.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType, itemType from List.inventory import magic, keyblade, ability, misc, report @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): Passage = "Passage" PassageChests = "Passage Chests" InnerChamber = "Inner Chamber" @@ -39,7 +39,7 @@ class NodeId(str, Enum): DataZexion = "Data Zexion" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): PassageMythrilShard = "Passage Mythril Shard" PassageMythrilStone = "Passage Mythril Stone" PassageEther = "Passage Ether" diff --git a/List/location/portroyal.py b/List/location/portroyal.py index 6c05290d..3355dcfa 100644 --- a/List/location/portroyal.py +++ b/List/location/portroyal.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType from List.inventory import magic, keyblade, ability, misc, summon, report @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): Rampart = "Ramparts" RampartChests = "Ramparts Chests" PortRoyalTown = "PR Town" @@ -34,7 +34,7 @@ class NodeId(str, Enum): DataLuxord = "Data Luxord" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): RampartNavalMap = "Rampart Naval Map" RampartMythrilStone = "Rampart Mythril Stone" RampartDarkShard = "Rampart Dark Shard" diff --git a/List/location/pridelands.py b/List/location/pridelands.py index 53093762..cc28428d 100644 --- a/List/location/pridelands.py +++ b/List/location/pridelands.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType from List.inventory import magic, keyblade, misc @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): Gorge = "Gorge" GorgeChests = "Gorge Chests" ElephantGraveyard = "Elephant Graveyard" @@ -30,7 +30,7 @@ class NodeId(str, Enum): DataSaix = "Data Saix" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): GorgeSavannahMap = "Gorge Savannah Map" GorgeDarkGem = "Gorge Dark Gem" GorgeMyhtrilStone = "Gorge Mythril Stone" diff --git a/List/location/puzzlereward.py b/List/location/puzzlereward.py index 9798237c..f01913de 100644 --- a/List/location/puzzlereward.py +++ b/List/location/puzzlereward.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from Class.newLocationClass import KH2Location from List.configDict import locationType, locationCategory, itemType @@ -6,7 +6,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): AwakeningPuzzle = "Awakening Puzzle" HeartPuzzle = "Heart Puzzle" DualityPuzzle = "Duality Puzzle" @@ -15,7 +15,7 @@ class NodeId(str, Enum): SunsetPuzzle = "Sunset Puzzle" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): AwakeningApBoost = "Awakening (AP Boost)" HeartSerenityCrystal = "Heart (Serenity Crystal)" DualityRareDocument = "Duality (Rare Document)" diff --git a/List/location/simulatedtwilighttown.py b/List/location/simulatedtwilighttown.py index 61181705..b1dd5f00 100644 --- a/List/location/simulatedtwilighttown.py +++ b/List/location/simulatedtwilighttown.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType from List.inventory import ability, misc @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): TwilightTownMapPopup = "Twilight Town Map Popup" MunnyPouchPopup = "Munny Pouch Popup" RoxasStation = "Roxas Station" @@ -32,7 +32,7 @@ class NodeId(str, Enum): DataRoxas = "Data Roxas" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): TwilightTownMap = "Twilight Town Map" MunnyPouchOlette = "Munny Pouch (Olette)" StationDusks = "Station Dusks" diff --git a/List/location/spaceparanoids.py b/List/location/spaceparanoids.py index 35bf130c..8e465000 100644 --- a/List/location/spaceparanoids.py +++ b/List/location/spaceparanoids.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType from List.inventory import ability, keyblade, magic @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): PitCell = "Pit Cell" PitCellChests = "Pit Cell Chests" Canyon = "Canyon" @@ -27,7 +27,7 @@ class NodeId(str, Enum): DataLarxene = "Data Larxene" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): PitCellAreaMap = "Pit Cell Area Map" PitCellMythrilCrystal = "Pit Cell Mythril Crystal" CanyonDarkCrystal = "Canyon Dark Crystal" diff --git a/List/location/starting.py b/List/location/starting.py index f42f9392..cce37715 100644 --- a/List/location/starting.py +++ b/List/location/starting.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from Class.newLocationClass import KH2Location from List.configDict import locationType @@ -6,7 +6,7 @@ from List.location.graph import chest, LocationGraphBuilder, START_NODE -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): GardenOfAssemblageMap = "Garden of Assemblage Map" GoaLostIllusion = "GoA Lost Illusion" ProofOfNonexistence = "Proof of Nonexistence" diff --git a/List/location/synthesis.py b/List/location/synthesis.py index f6a33dc1..5759e76f 100644 --- a/List/location/synthesis.py +++ b/List/location/synthesis.py @@ -1,11 +1,11 @@ -from enum import Enum +from enum import StrEnum from Class.newLocationClass import KH2Location from List.configDict import locationType, itemType, locationCategory from List.location.graph import LocationGraphBuilder, START_NODE -class NodeId(str, Enum): +class NodeId(StrEnum): FreeDev1 = "Synthesis Free Dev 1" FreeDev1Part2 = "Synthesis Free Dev 1 Part 2" FreeDev1Part3 = "Synthesis Free Dev 1 Part 3" diff --git a/List/location/twilighttown.py b/List/location/twilighttown.py index 2b06aaa3..969bab19 100644 --- a/List/location/twilighttown.py +++ b/List/location/twilighttown.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType from List.inventory import keyblade, ability, report, form, misc @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): OldMansion = "Old Mansion" OldMansionChests = "Old Mansion Chests" Woods = "Woods" @@ -48,7 +48,7 @@ class NodeId(str, Enum): DataAxel = "Data Axel" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): OldMansionPotion = "Old Mansion Potion" OldMansionMythrilShard = "Old Mansion Mythril Shard" WoodsPotion = "The Woods Potion" diff --git a/List/location/worldthatneverwas.py b/List/location/worldthatneverwas.py index 36ab5c59..e8ce3323 100644 --- a/List/location/worldthatneverwas.py +++ b/List/location/worldthatneverwas.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import StrEnum from List.configDict import locationType, itemType from List.inventory import keyblade, ability, magic, report @@ -7,7 +7,7 @@ from Module.itemPlacementRestriction import ItemPlacementHelpers -class NodeId(str, Enum): +class NodeId(StrEnum): FragmentCrossing = "Fragment Crossing" FragmentCrossingChests = "Fragment Crossing Chests" Roxas = "Roxas" @@ -33,7 +33,7 @@ class NodeId(str, Enum): DataXemnas = "Data Xemnas" -class CheckLocation(str, Enum): +class CheckLocation(StrEnum): FragmentCrossingMythrilStone = "Fragment Crossing Mythril Stone" FragmentCrossingMythrilCrystal = "Fragment Crossing Mythril Crystal" FragmentCrossingApBoost = "Fragment Crossing AP Boost" diff --git a/Module/compat.py b/Module/compat.py new file mode 100644 index 00000000..faeab84e --- /dev/null +++ b/Module/compat.py @@ -0,0 +1,18 @@ +""" +Compatibility patches for third-party libraries. + +Import this module before importing kh2fmbr/khbr. +""" +import yaml + +# kh2fmbr calls yaml.load without a Loader, which PyYAML 6 no longer allows. +# Make bare yaml.load default to SafeLoader (PyYAML 5.x used FullLoader with a +# warning; the data files involved are plain YAML, so SafeLoader suffices). +if not getattr(yaml, "_kh2rando_load_patched", False): + _original_load = yaml.load + + def _load_with_default_loader(stream, Loader=None): + return _original_load(stream, Loader=Loader or yaml.SafeLoader) + + yaml.load = _load_with_default_loader + yaml._kh2rando_load_patched = True diff --git a/Module/cosmetics.py b/Module/cosmetics.py index 85150b27..904c9f95 100644 --- a/Module/cosmetics.py +++ b/Module/cosmetics.py @@ -5,7 +5,7 @@ from Class import settingkey from Class.openkhmod import ModAsset, StrDict from Class.seedSettings import SeedSettings -from Module import appconfig +from Module import appconfig, platformutils from Module.cosmeticsmods import music from Module.cosmeticsmods.endingpic import EndingPictureRandomizer from Module.cosmeticsmods.field2d import CommandMenuRandomizer, RoomTransitionImageRandomizer @@ -131,7 +131,7 @@ def add_game_song(song_file_path: Path, category: str, song_dmca: bool): if kh2_path.is_dir(): for kh2_song in music.kh2_music_list: add_game_song( - song_file_path=kh2_path / kh2_song['filename'], + song_file_path=kh2_path / platformutils.fs_relative(kh2_song['filename']), category=kh2_song['type'][0].lower(), song_dmca=kh2_song.get('dmca', False) ) @@ -140,7 +140,7 @@ def add_other_game_music(enabled_key: str, game_music_path: Path, game_music_lis if settings.get(enabled_key) and game_music_path.is_dir(): for song in game_music_list: add_game_song( - song_file_path=game_music_path / song['name'], + song_file_path=game_music_path / platformutils.fs_relative(song['name']), category=song['kind'], song_dmca=song.get('dmca', False) ) diff --git a/Module/cosmeticsmods/keyblade.py b/Module/cosmeticsmods/keyblade.py index f45bbe42..7127e0f9 100644 --- a/Module/cosmeticsmods/keyblade.py +++ b/Module/cosmeticsmods/keyblade.py @@ -2,7 +2,7 @@ import random import shutil import struct -from enum import Enum +from enum import StrEnum from pathlib import Path from typing import Optional, Any @@ -20,7 +20,7 @@ REMASTERED_TEXTURES = "remastered-textures" -class KeybladeModelVariant(str, Enum): +class KeybladeModelVariant(StrEnum): BASE = "base" NIGHTMARE = "nm" TRON = "tr" diff --git a/Module/cosmeticsmods/openkh.py b/Module/cosmeticsmods/openkh.py index aa90d488..6acbc3b7 100644 --- a/Module/cosmeticsmods/openkh.py +++ b/Module/cosmeticsmods/openkh.py @@ -2,7 +2,7 @@ from pathlib import Path from Class.exceptions import GeneratorException -from Module import appconfig +from Module import appconfig, platformutils class BinaryArchiver: @@ -21,6 +21,8 @@ def __init__(self): if not bar_exe.is_file(): raise GeneratorException("No OpenKh.Command.Bar.exe found.") + platformutils.ensure_windows_exe_runnable() + self.bar_exe = bar_exe def extract_bar(self, bar_file: Path, destination: Path): @@ -29,11 +31,11 @@ def extract_bar(self, bar_file: Path, destination: Path): """ destination.mkdir(parents=True, exist_ok=True) # -o specifies the output location - args = [self.bar_exe, "unpack", "-o", destination, bar_file] - subprocess.call(args, creationflags=subprocess.CREATE_NO_WINDOW) + args = platformutils.windows_exe_command(self.bar_exe, ["unpack", "-o", destination, bar_file]) + subprocess.call(args, creationflags=platformutils.no_window_flags()) def create_bar(self, bar_json_file: Path, destination: Path): """Packs a BAR file to the destination file from a JSON "project file".""" # -o specifies the output location - args = [self.bar_exe, "pack", "-o", destination, bar_json_file] - subprocess.call(args, creationflags=subprocess.CREATE_NO_WINDOW) + args = platformutils.windows_exe_command(self.bar_exe, ["pack", "-o", destination, bar_json_file]) + subprocess.call(args, creationflags=platformutils.no_window_flags()) diff --git a/Module/platformutils.py b/Module/platformutils.py new file mode 100644 index 00000000..823d31cc --- /dev/null +++ b/Module/platformutils.py @@ -0,0 +1,96 @@ +""" +Helpers for code that needs to behave differently across operating systems. +""" +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + +from Class.exceptions import GeneratorException + + +def is_windows() -> bool: + return sys.platform == "win32" + + +def is_linux() -> bool: + return sys.platform.startswith("linux") + + +def running_as_bundle() -> bool: + """True when running from a PyInstaller bundle rather than from source.""" + return hasattr(sys, "_MEIPASS") + + +def appimage_path() -> Optional[Path]: + """Path of the running AppImage (Linux only; set by the AppImage runtime).""" + path = os.environ.get("APPIMAGE") + return Path(path) if path else None + + +def no_window_flags() -> int: + """subprocess creation flags that suppress a console window on Windows.""" + return subprocess.CREATE_NO_WINDOW if is_windows() else 0 + + +def open_folder(path): + """Opens a folder in the OS file manager.""" + from PySide6.QtCore import QUrl + from PySide6.QtGui import QDesktopServices + QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) + + +def prepare_qt_environment(): + """ + Call before creating the QApplication. On Linux, pick a platform theme (unless the + user configured one) so file dialogs use the desktop's native file picker instead + of Qt's built-in one, which inherits the app stylesheet. GTK-based desktops get the + gtk3 theme, which talks to GTK directly and doesn't need the xdg-desktop-portal + daemon; everything else goes through the portal. + """ + if not is_linux() or os.environ.get("QT_QPA_PLATFORMTHEME"): + return + + desktop = ":".join([ + os.environ.get("XDG_CURRENT_DESKTOP", ""), + os.environ.get("DESKTOP_SESSION", ""), + ]).lower() + gtk_desktops = ("gnome", "cinnamon", "mate", "xfce", "unity", "budgie", "lxde") + if any(name in desktop for name in gtk_desktops): + os.environ["QT_QPA_PLATFORMTHEME"] = "gtk3" + else: + os.environ["QT_QPA_PLATFORMTHEME"] = "xdgdesktopportal" + + +def wine_available() -> bool: + return shutil.which("wine") is not None + + +def ensure_windows_exe_runnable(): + """Raises a GeneratorException if Windows executables can't run on this system.""" + if not is_windows() and not wine_available(): + raise GeneratorException( + "Running Windows tools (such as the OpenKH tools) on this system requires" + " Wine, which was not found. Install wine using your distribution's package" + " manager and try again." + ) + + +def windows_exe_command(exe_path, args: list) -> list: + """ + Command list that runs a Windows executable on the current platform (natively on + Windows, through wine elsewhere). Wine exposes absolute Unix paths via its Z: + drive, so path arguments need no translation. + """ + command = [str(exe_path)] + [str(arg) for arg in args] + if is_windows(): + return command + ensure_windows_exe_runnable() + return ["wine"] + command + + +def fs_relative(data_path: str) -> Path: + """Converts a backslash-separated archive-internal path to a relative Path.""" + return Path(data_path.replace("\\", "/")) diff --git a/Module/zipper.py b/Module/zipper.py index 0a555ab3..e46478bb 100644 --- a/Module/zipper.py +++ b/Module/zipper.py @@ -295,6 +295,7 @@ def _invoke_khbr_with_overrides( # return create_spoiler_text(game_data.spoilers) ### Backup old way + from Module import compat as _compat # noqa: F401 - must precede kh2fmbr import from kh2fmbr.randomizer import Randomizer as BossEnemyRandomizer enemySpoilers = BossEnemyRandomizer().generateToZip("kh2", enemy_options, mod, out_zip) return enemySpoilers diff --git a/README.md b/README.md index 6ecd486b..7437637e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,41 @@ ![Screenshot of the seed generator user interface](docs/seed-generator-screenshot.png) +## Running on Linux + +The seed generator runs natively on Linux, either from source or as an AppImage. + +### From source + +Requires Python 3.12 or newer. + +```sh +python -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python localUI.py +``` + +Runtime notes: + +- Copying seed strings to the clipboard needs a clipboard utility: `wl-clipboard` + on Wayland or `xclip`/`xsel` on X11. +- Some distributions need Qt's xcb runtime libraries for PySide6 (for example, + `libxcb-cursor0` on Debian/Ubuntu). +- Cosmetics features that use the OpenKH tools (keyblade randomization, texture + recolors) run the OpenKH Windows executables through [Wine](https://www.winehq.org/), + so `wine` must be installed for those features. Everything else works without it. +- `extracted_data.zip` (bundled with releases) is needed in the repo root for the + first-launch data extraction, the same as when building the Windows executable. + +### AppImage + +Download `KH2.Randomizer-x86_64.AppImage` from a release (when available), mark it +executable (`chmod +x`), and run it. The in-app updater downloads new AppImage +releases and replaces itself in place. `--updater` opens the updater directly. + +To build the AppImage yourself: `packaging/linux/build_appimage.sh` (see the +comments at the top of the script for prerequisites). + ## Acknowledgements Icons and font by Televo diff --git a/UI/GithubInfo/releaseInfo.py b/UI/GithubInfo/releaseInfo.py index 5f907e2c..a6c3934d 100644 --- a/UI/GithubInfo/releaseInfo.py +++ b/UI/GithubInfo/releaseInfo.py @@ -1,11 +1,37 @@ import os +from pathlib import Path +from typing import Optional + import requests from packaging import version -from PySide6.QtWidgets import QProgressDialog +from PySide6.QtWidgets import QMessageBox, QProgressDialog +from Module import platformutils from Module.version import LOCAL_UI_VERSION +WINDOWS_ASSET_NAME = "KH2.Randomizer.exe" + + +def _is_platform_asset(asset_name: str) -> bool: + """True if a release asset is the download for the current platform.""" + if platformutils.is_windows(): + return asset_name.endswith(".exe") + else: + return asset_name.endswith(".AppImage") + + +def update_install_target() -> Optional[Path]: + """ + Where a downloaded update gets installed, or None if self-updating isn't possible + (on Linux, only an AppImage can replace itself; a source checkout updates via git). + """ + if platformutils.is_windows(): + return Path(WINDOWS_ASSET_NAME).absolute() + else: + return platformutils.appimage_path() + + class GithubReleaseInfo: def __init__(self, info_json): self.notes = info_json["body"] @@ -18,11 +44,23 @@ def __init__(self, info_json): self.download_link = None self.updated_time = None for asset in info_json["assets"]: - if ".exe" in asset["name"]: + if _is_platform_asset(asset["name"]): self.download_link = asset["browser_download_url"] self.updated_time = asset["updated_at"] def download_release(self): + target = update_install_target() + if target is None: + message = QMessageBox(text=( + "The seed generator can't update itself when running from source." + " Update your checkout (for example, with git pull) instead." + )) + message.setWindowTitle("KH2 Seed Generator") + message.exec() + return False + + temp_target = target.parent / (target.name + ".tmp") + progress = QProgressDialog( f"Downloading version {self.version}", None, 0, 100, None ) @@ -32,16 +70,18 @@ def download_release(self): with requests.get(self.download_link, stream=True) as response: num_bytes = int(response.headers["Content-Length"]) bytes_downloaded = 0 - with open("KH2.Randomizer.exe.tmp", mode="wb") as file: + with open(temp_target, mode="wb") as file: release_chunk_size = 50 * 1024 for chunk in response.iter_content(chunk_size=release_chunk_size): bytes_downloaded += release_chunk_size - progress.setValue(bytes_downloaded*100.0/num_bytes) + progress.setValue(int(bytes_downloaded * 100.0 / num_bytes)) file.write(chunk) - os.replace("KH2.Randomizer.exe.tmp", "KH2.Randomizer.exe") + if not platformutils.is_windows(): + os.chmod(temp_target, 0o755) + os.replace(temp_target, target) progress.close() return True - + def __str__(self): return f"{self.version} {self.updated_time} : {self.notes}" @@ -58,12 +98,13 @@ def __init__(self): # THIS LINE IS FOR TESTING PURPOSES ONLY # self.current_version = version.parse("2.2.0") self.current_version = version.parse(LOCAL_UI_VERSION) - # if we have a version that is higher than current version, add it to update list + # if we have a version that is higher than current version and has a + # download for this platform, add it to update list for info in self.infos: - if self.current_version < info.version: + if self.current_version < info.version and info.download_link is not None: self.potential_updates.append(info) except: # not doing anything if we can't connect to internet or other error occurs pass def get_update_infos(self): - return self.potential_updates \ No newline at end of file + return self.potential_updates diff --git a/UI/Submenus/CosmeticsMenu.py b/UI/Submenus/CosmeticsMenu.py index b9264bf3..852c5173 100644 --- a/UI/Submenus/CosmeticsMenu.py +++ b/UI/Submenus/CosmeticsMenu.py @@ -8,7 +8,7 @@ from Class import settingkey from Class.seedSettings import SeedSettings, ExtraConfigurationData from List import configDict -from Module import appconfig +from Module import appconfig, platformutils from Module.cosmetics import CustomCosmetics, CosmeticsMod from UI import configui from UI.Submenus.CustomVisualsDialogs import ItempicViewerDialog, RoomTransitionViewerDialog, EndingPictureViewerDialog, \ @@ -255,7 +255,12 @@ def _reload_custom_executables_list(self): def _add_custom_executable(self): file_dialog = QFileDialog() - outfile_name, _ = file_dialog.getOpenFileName(self, filter='Executables (*.exe *.bat)') + if platformutils.is_windows(): + name_filter = 'Executables (*.exe *.bat)' + else: + # Linux executables are often extensionless, so allow choosing any file + name_filter = 'Executables (*.exe *.sh *);;All files (*)' + outfile_name, _ = file_dialog.getOpenFileName(self, filter=name_filter) if outfile_name != '': self.custom_cosmetics.add_custom_executable(outfile_name) self._reload_custom_executables_list() @@ -310,7 +315,7 @@ def _set_up_custom_music(self): def _open_custom_music_folder(): custom_music_path = appconfig.read_custom_music_path() if custom_music_path is not None: - os.startfile(custom_music_path) + platformutils.open_folder(custom_music_path) def _make_cosmetics_only_mod(self): extra_data = ExtraConfigurationData( diff --git a/UI/Submenus/CustomVisualsDialogs.py b/UI/Submenus/CustomVisualsDialogs.py index 11dd06c1..eb94a420 100644 --- a/UI/Submenus/CustomVisualsDialogs.py +++ b/UI/Submenus/CustomVisualsDialogs.py @@ -6,7 +6,7 @@ from PySide6.QtCore import QSize from PySide6.QtWidgets import QDialog, QVBoxLayout, QWidget, QLabel, QScrollArea, QGridLayout, QMenuBar, QMenu, QFrame -from Module import appconfig +from Module import appconfig, platformutils from Module.cosmeticsmods.endingpic import EndingPictureRandomizer from Module.cosmeticsmods.field2d import RoomTransitionImageRandomizer, CommandMenuRandomizer from Module.cosmeticsmods.itempic import ItempicRandomizer @@ -109,7 +109,7 @@ def open_custom_visuals_folder(self): else: folder_path = custom_visuals_path / self.folder_name folder_path.mkdir(parents=True, exist_ok=True) - os.startfile(folder_path) + platformutils.open_folder(folder_path) def _configure_custom_visuals(self): if configui.custom_visuals_folder_getter(): diff --git a/UI/Submenus/DevCreateRecolorDialog.py b/UI/Submenus/DevCreateRecolorDialog.py index 0f8ce981..10a667b5 100644 --- a/UI/Submenus/DevCreateRecolorDialog.py +++ b/UI/Submenus/DevCreateRecolorDialog.py @@ -26,7 +26,7 @@ def __init__(self): row = 0 grid.addWidget(QLabel("Image File"), row, 0) - self.image_path_field = QLineEdit(r"C:\games\kh2\extract\kh2\remastered\obj\P_EX100.mdlx\-0.dds") + self.image_path_field = QLineEdit(str(Path.home())) self.image_path_field.textChanged.connect(self._do_preview) grid.addWidget(self.image_path_field, row, 1, 1, 2) row = row + 1 diff --git a/UI/Submenus/ManageKeybladesDialog.py b/UI/Submenus/ManageKeybladesDialog.py index 54248ef7..99f4f95d 100644 --- a/UI/Submenus/ManageKeybladesDialog.py +++ b/UI/Submenus/ManageKeybladesDialog.py @@ -9,7 +9,7 @@ QFileDialog from Class.seedSettings import SeedSettings -from Module import appconfig +from Module import appconfig, platformutils from Module.cosmeticsmods.keyblade import KeybladeRandomizer, ReplacementKeyblade from UI import theme, configui from UI.Submenus.KeybladePackageDialog import KeybladePackageDialog, KeybladeModImportDialog @@ -137,7 +137,7 @@ def _open_custom_keyblades_folder(self): else: keyblades_path = custom_visuals_path / KeybladeRandomizer.directory_name() keyblades_path.mkdir(parents=True, exist_ok=True) - os.startfile(keyblades_path) + platformutils.open_folder(keyblades_path) def _configure_custom_visuals(self): if configui.custom_visuals_folder_getter(): diff --git a/UI/Submenus/TextureRecolorSettingsDialog.py b/UI/Submenus/TextureRecolorSettingsDialog.py index ad07cc2c..b100b556 100644 --- a/UI/Submenus/TextureRecolorSettingsDialog.py +++ b/UI/Submenus/TextureRecolorSettingsDialog.py @@ -15,7 +15,7 @@ from Class import settingkey from Class.seedSettings import SeedSettings -from Module import appconfig +from Module import appconfig, platformutils from Module.cosmeticsmods import texture from Module.cosmeticsmods.texture import TextureRecolorSettings, TextureRecolorizer, recolor_image, RecolorDefinition, \ TextureConditionsLoader @@ -353,7 +353,7 @@ def _update_ui_for_selected_area(self): @staticmethod def _open_preset_folder(): - os.startfile(TextureRecolorSettings.texture_recolors_presets_folder()) + platformutils.open_folder(TextureRecolorSettings.texture_recolors_presets_folder()) def _import_preset(self): file_dialog = QFileDialog(self) diff --git a/UI/configui.py b/UI/configui.py index 11715271..4ec4c998 100644 --- a/UI/configui.py +++ b/UI/configui.py @@ -20,7 +20,8 @@ def openkh_folder_getter() -> bool: return False selected_path = Path(selected_directory) - if not (selected_path / "OpenKh.Tools.ModsManager.exe").is_file(): + mods_manager_names = ("OpenKh.Tools.ModsManager.exe", "OpenKh.Tools.ModsManager") + if not any((selected_path / name).is_file() for name in mods_manager_names): show_alert("Not a valid OpenKH folder.") return False else: diff --git a/UI/worker.py b/UI/worker.py index 0766e07f..3d70d67e 100644 --- a/UI/worker.py +++ b/UI/worker.py @@ -1,3 +1,4 @@ +import os import subprocess from io import BytesIO from pathlib import Path @@ -6,9 +7,9 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QProgressDialog, QFileDialog, QWidget, QMessageBox -from Class.exceptions import RandomizerExceptions +from Class.exceptions import GeneratorException, RandomizerExceptions from Class.seedSettings import SeedSettings, ExtraConfigurationData -from Module import appconfig +from Module import appconfig, platformutils from Module.RandomizerSettings import RandomizerSettings from Module.generate import generateSeed, generateMultiWorldSeed from Module.zipper import BossEnemyOnlyZip, CosmeticsOnlyZip, SeedZipResult @@ -27,7 +28,21 @@ def run_custom_cosmetics_executables(extra_data: ExtraConfigurationData): custom_file_path = Path(custom_executable) if custom_file_path.is_file(): custom_cwd = custom_file_path.parent - subprocess.call(custom_file_path, cwd=custom_cwd) + if platformutils.is_windows(): + subprocess.call([str(custom_file_path)], cwd=custom_cwd) + elif custom_file_path.suffix.lower() == ".exe": + subprocess.call( + platformutils.windows_exe_command(custom_file_path, []), cwd=custom_cwd + ) + elif os.access(custom_file_path, os.X_OK): + subprocess.call([str(custom_file_path)], cwd=custom_cwd) + elif custom_file_path.suffix.lower() == ".sh": + subprocess.call(["/bin/sh", str(custom_file_path)], cwd=custom_cwd) + else: + raise GeneratorException( + f"{custom_file_path.name} is not executable. Mark it executable" + " (chmod +x) or choose a .sh or .exe file." + ) def download_mod(self, zip_data: BytesIO, output_file_name: str, title: str): last_save_path = appconfig.read_last_save_path() diff --git a/cli_requirements.txt b/cli_requirements.txt index 44b0dead..a10e0019 100644 --- a/cli_requirements.txt +++ b/cli_requirements.txt @@ -1,5 +1,4 @@ -altgraph==0.17.2 bitstring==3.1.9 khbr==4.0.5 -pillow==9.3.0 -PyYAML==5.4.1 \ No newline at end of file +pillow>=11.0 +PyYAML>=6.0.2 diff --git a/linux_main.py b/linux_main.py new file mode 100644 index 00000000..5da20f46 --- /dev/null +++ b/linux_main.py @@ -0,0 +1,14 @@ +""" +Entry point for the Linux bundle. The same executable serves as both the main app and +the updater (via --updater), since the AppImage can't ship a second executable the way +the Windows build bundles updater.exe. +""" +import sys + +if __name__ == "__main__": + if "--updater" in sys.argv: + import updater + updater.main() + else: + import localUI + localUI.main() diff --git a/localUI.py b/localUI.py index ccbd45ac..68b882c3 100644 --- a/localUI.py +++ b/localUI.py @@ -25,7 +25,7 @@ from Class.exceptions import CantAssignItemException, RandomizerExceptions, SettingsException from Class.randomUtils import unseeded_rng, random_seed_name from Class.seedSettings import SeedSettings, ExtraConfigurationData, randomize_settings -from Module import appconfig, hashimage, version +from Module import appconfig, hashimage, platformutils, version from Module.RandomizerSettings import RandomizerSettings from Module.cosmetics import CosmeticsMod, CustomCosmetics from Module.dailySeed import allDailyModifiers, getDailyModifiers @@ -413,8 +413,14 @@ def _configure_menu_bar(self): menu_bar.addAction("About", self.show_about) def _update_generator(self): - #invoke the update exe - process = subprocess.Popen(resource_path("updater.exe")) + #invoke the updater and exit so the main executable can be replaced + if platformutils.is_windows(): + process = subprocess.Popen(resource_path("updater.exe")) + elif platformutils.appimage_path() is not None: + process = subprocess.Popen([str(platformutils.appimage_path()), "--updater"]) + else: + # running from source + process = subprocess.Popen([sys.executable, resource_path("updater.py")]) sys.exit() def _build_progress_frame(self) -> QFrame: @@ -897,7 +903,7 @@ def randomize_the_settings(self): return None,None def openPresetFolder(self): - os.startfile(appconfig.settings_presets_folder()) + platformutils.open_folder(appconfig.settings_presets_folder()) def _use_preset(self, preset: SettingsPreset): self.recalculate = False @@ -1039,7 +1045,8 @@ def _dev_create_recolor(): DevCreateRecolorDialog().exec() -if __name__ == "__main__": +def main(): + platformutils.prepare_qt_environment() app = QApplication([]) QtGui.QFontDatabase.addApplicationFont(resource_path('static/KHMenu.otf')) @@ -1110,3 +1117,7 @@ def _dev_create_recolor(): window.move(center.x() - window.width() / 2, center.y() - window.height() / 2) sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun new file mode 100755 index 00000000..7116acb1 --- /dev/null +++ b/packaging/linux/AppRun @@ -0,0 +1,4 @@ +#!/bin/sh +# AppImage entry point. Arguments (such as --updater) pass through to the app. +HERE="$(dirname "$(readlink -f "$0")")" +exec "$HERE/usr/bin/kh2randomizer/kh2randomizer" "$@" diff --git a/packaging/linux/build_appimage.sh b/packaging/linux/build_appimage.sh new file mode 100755 index 00000000..daed0b1c --- /dev/null +++ b/packaging/linux/build_appimage.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Builds KH2.Randomizer-x86_64.AppImage from the repo root. +# +# Requirements: +# - A Python environment with requirements.txt installed (default: .venv in the +# repo root; override with PYTHON=/path/to/python) +# - extracted_data.zip present in the repo root (same as the Windows build) +# - appimagetool on PATH (downloaded automatically if missing) +# +# For maximum compatibility, run the build on the oldest distro you intend to +# support (the bundled glibc floor comes from the build machine). +set -euo pipefail + +cd "$(dirname "$0")/../.." + +PYTHON=${PYTHON:-.venv/bin/python} +BUILD_DIR=build/appimage +APPDIR="$BUILD_DIR/AppDir" +OUTPUT_NAME="KH2.Randomizer-x86_64.AppImage" + +if [ ! -f extracted_data.zip ]; then + echo "error: extracted_data.zip not found in the repo root (required for bundling)" >&2 + exit 1 +fi + +"$PYTHON" -m PyInstaller --noconfirm "KH2 Randomizer Linux.spec" + +rm -rf "$APPDIR" +mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/icons/hicolor/256x256/apps" +cp -r dist/kh2randomizer "$APPDIR/usr/bin/kh2randomizer" +install -m 755 packaging/linux/AppRun "$APPDIR/AppRun" +cp packaging/linux/kh2randomizer.desktop "$APPDIR/" +"$PYTHON" -c "from PIL import Image; Image.open('rando.ico').save('$APPDIR/kh2randomizer.png')" +cp "$APPDIR/kh2randomizer.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/kh2randomizer.png" + +APPIMAGETOOL=${APPIMAGETOOL:-appimagetool} +if ! command -v "$APPIMAGETOOL" >/dev/null 2>&1; then + APPIMAGETOOL="$BUILD_DIR/appimagetool-x86_64.AppImage" + if [ ! -x "$APPIMAGETOOL" ]; then + echo "Downloading appimagetool..." + curl -fL -o "$APPIMAGETOOL" \ + "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" + chmod +x "$APPIMAGETOOL" + fi +fi + +ARCH=x86_64 "$APPIMAGETOOL" "$APPDIR" "$OUTPUT_NAME" +echo "Built $OUTPUT_NAME" diff --git a/packaging/linux/kh2randomizer.desktop b/packaging/linux/kh2randomizer.desktop new file mode 100644 index 00000000..c6de49a4 --- /dev/null +++ b/packaging/linux/kh2randomizer.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=KH2 Randomizer +Comment=Kingdom Hearts II Final Mix randomizer seed generator +Exec=kh2randomizer +Icon=kh2randomizer +Categories=Game; +Terminal=false diff --git a/requirements.txt b/requirements.txt index 3e948424..c5989a30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,16 @@ -altgraph==0.17.2 -bitstring==3.1.9 -future==0.18.2 -kh2fmbr==0.0.1 -numpy==1.25.2 -pefile==2021.9.3 -pillow==9.3.0 -pyinstaller==4.5.1 -pyinstaller-hooks-contrib==2024.1 -pyperclip==1.8.2 -PySide6==6.2.1 -pytz==2021.3 -pywin32-ctypes==0.2.0 -PyYAML==5.4.1 -requests==2.31.0 -shiboken6==6.2.1 -tomlkit==0.11.1 \ No newline at end of file +bitstring==3.1.9 +kh2fmbr==0.0.1 +numpy==2.5.1 +packaging==26.2 +pefile==2024.8.26 ; sys_platform == "win32" +pillow==12.3.0 +pyinstaller==6.21.0 +pyinstaller-hooks-contrib==2026.6 +pyperclip==1.8.2 +PySide6==6.11.1 +pytz==2026.2 +pywin32-ctypes==0.2.0 ; sys_platform == "win32" +PyYAML==6.0.3 +requests==2.34.2 +shiboken6==6.11.1 +tomlkit==0.11.1 diff --git a/updater.py b/updater.py index b6e2f767..1e96b0ea 100644 --- a/updater.py +++ b/updater.py @@ -7,9 +7,10 @@ from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication, QMainWindow, QLabel, QGridLayout, QWidget, QListWidget, QPushButton, QPlainTextEdit +from Module import platformutils from Module.resources import resource_path from UI import theme -from UI.GithubInfo.releaseInfo import GithubReleaseInfo, KH2RandomizerGithubReleases +from UI.GithubInfo.releaseInfo import GithubReleaseInfo, KH2RandomizerGithubReleases, update_install_target class KH2RandoUpdater(QMainWindow): def __init__(self): @@ -51,12 +52,16 @@ def download_selected_update(self): update_info : GithubReleaseInfo = self.updates[index.row()] result = update_info.download_release() if result: - process = subprocess.Popen("KH2.Randomizer.exe") + if platformutils.is_windows(): + process = subprocess.Popen("KH2.Randomizer.exe") + else: + process = subprocess.Popen([str(update_install_target())]) sys.exit() -if __name__=="__main__": +def main(): + platformutils.prepare_qt_environment() app = QApplication([]) QtGui.QFontDatabase.addApplicationFont(resource_path('static/KHMenu.otf')) window = KH2RandoUpdater() @@ -75,3 +80,7 @@ def download_selected_update(self): app.setStyleSheet((stylesheet + file.read().format(**os.environ)) % css_resources) window.show() sys.exit(app.exec()) + + +if __name__=="__main__": + main() From 0427b51047981e2f9da1858f21cba34304d36e76 Mon Sep 17 00:00:00 2001 From: Zexyen Date: Sat, 25 Jul 2026 19:01:26 -0400 Subject: [PATCH 02/10] Fix checkbox settings under PySide6 6.x and integrate with native OpenKH - Fix all checkbox settings silently storing False: stateChanged emits an int, which no longer compares equal to Qt.Checked (a pure Python enum in PySide6 6.4+); use toggled(bool) instead. Building the UI was resetting every checked toggle (Critical Bonuses, music rando, etc.) to off. - Run OpenKH tools natively on Linux: prefer a native binary, then the framework-dependent .dll via dotnet --roll-forward, then wine as a last resort. - Translate Wine-style Z:\ paths when reading config files (OpenKH's mods-manager.yml gameDataPath written by tools that ran under Wine). - Accept OpenKh.Tools.ModsManager.Avalonia as a valid OpenKH folder marker. --- Module/appconfig.py | 6 +++-- Module/cosmeticsmods/openkh.py | 12 +++------- Module/platformutils.py | 44 ++++++++++++++++++++++++++++++++++ UI/Submenus/SubMenu.py | 2 +- UI/configui.py | 6 ++++- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/Module/appconfig.py b/Module/appconfig.py index 47e0e31b..b0fab6e2 100644 --- a/Module/appconfig.py +++ b/Module/appconfig.py @@ -4,6 +4,8 @@ import yaml +from Module import platformutils + AUTOSAVE_FOLDER = "auto-save" PRESET_FOLDER = "presets" @@ -59,7 +61,7 @@ def _read_directory(key: str) -> Optional[Path]: if value is None: return None - candidate = Path(value) + candidate = platformutils.path_from_config_value(value) if candidate.is_dir(): return candidate else: @@ -116,7 +118,7 @@ def extracted_data_path() -> Optional[Path]: # Regardless, nothing we can do here. return None - extracted_data = Path(game_data_path) + extracted_data = platformutils.path_from_config_value(game_data_path) if extracted_data.is_dir(): return extracted_data else: diff --git a/Module/cosmeticsmods/openkh.py b/Module/cosmeticsmods/openkh.py index 6acbc3b7..cc1dc0d6 100644 --- a/Module/cosmeticsmods/openkh.py +++ b/Module/cosmeticsmods/openkh.py @@ -17,13 +17,7 @@ def __init__(self): if openkh_path is None: raise GeneratorException("No OpenKH path configured.") - bar_exe = openkh_path / "OpenKh.Command.Bar.exe" - if not bar_exe.is_file(): - raise GeneratorException("No OpenKh.Command.Bar.exe found.") - - platformutils.ensure_windows_exe_runnable() - - self.bar_exe = bar_exe + self.bar_launcher = platformutils.openkh_tool_launcher(openkh_path, "OpenKh.Command.Bar") def extract_bar(self, bar_file: Path, destination: Path): """ @@ -31,11 +25,11 @@ def extract_bar(self, bar_file: Path, destination: Path): """ destination.mkdir(parents=True, exist_ok=True) # -o specifies the output location - args = platformutils.windows_exe_command(self.bar_exe, ["unpack", "-o", destination, bar_file]) + args = self.bar_launcher + ["unpack", "-o", str(destination), str(bar_file)] subprocess.call(args, creationflags=platformutils.no_window_flags()) def create_bar(self, bar_json_file: Path, destination: Path): """Packs a BAR file to the destination file from a JSON "project file".""" # -o specifies the output location - args = platformutils.windows_exe_command(self.bar_exe, ["pack", "-o", destination, bar_json_file]) + args = self.bar_launcher + ["pack", "-o", str(destination), str(bar_json_file)] subprocess.call(args, creationflags=platformutils.no_window_flags()) diff --git a/Module/platformutils.py b/Module/platformutils.py index 823d31cc..4316f05c 100644 --- a/Module/platformutils.py +++ b/Module/platformutils.py @@ -91,6 +91,50 @@ def windows_exe_command(exe_path, args: list) -> list: return ["wine"] + command +def openkh_tool_launcher(openkh_path: Path, tool_name: str) -> list: + """ + Command prefix that runs the given OpenKH tool (e.g. "OpenKh.Command.Bar") on the + current platform; append the tool's arguments to it. On Windows this is the .exe. + Elsewhere, prefers a native Linux build of the tool, then the framework-dependent + .dll via the dotnet runtime, then the .exe through wine. + """ + exe = openkh_path / f"{tool_name}.exe" + if is_windows(): + if not exe.is_file(): + raise GeneratorException(f"No {tool_name}.exe found.") + return [str(exe)] + + native = openkh_path / tool_name + if native.is_file() and os.access(native, os.X_OK): + return [str(native)] + + dll = openkh_path / f"{tool_name}.dll" + if dll.is_file() and shutil.which("dotnet") is not None: + # OpenKH releases target an older .NET; roll forward to whatever is installed + return ["dotnet", "--roll-forward", "LatestMajor", str(dll)] + + if exe.is_file(): + if wine_available(): + return ["wine", str(exe)] + raise GeneratorException( + f"Found {tool_name} in the OpenKH folder, but no way to run it on this" + " system. Install the .NET runtime (dotnet) or Wine and try again." + ) + + raise GeneratorException(f"No {tool_name} found in the OpenKH folder.") + + def fs_relative(data_path: str) -> Path: """Converts a backslash-separated archive-internal path to a relative Path.""" return Path(data_path.replace("\\", "/")) + + +def path_from_config_value(value: str) -> Path: + """ + Interprets a path read from a config file. On Linux, translates Wine-style paths + (Z:\\home\\...) that tools previously running under Wine may have written, since + wine's Z: drive maps to the filesystem root. + """ + if not is_windows() and len(value) >= 2 and value[0] in "Zz" and value[1] == ":": + return Path(value[2:].replace("\\", "/")) + return Path(value) diff --git a/UI/Submenus/SubMenu.py b/UI/Submenus/SubMenu.py index 42bd1e69..943f7904 100644 --- a/UI/Submenus/SubMenu.py +++ b/UI/Submenus/SubMenu.py @@ -497,7 +497,7 @@ def make_combo_box(self, name: str): def make_check_box(self, name: str): check_box = QCheckBox() check_box.setCheckState(Qt.Checked if self.settings.get(name) else Qt.Unchecked) - check_box.stateChanged.connect(lambda state: self.settings.set(name, state == Qt.Checked)) + check_box.toggled.connect(lambda checked: self.settings.set(name, checked)) return check_box def make_int_spin_box(self, name: str): diff --git a/UI/configui.py b/UI/configui.py index 4ec4c998..c0377f00 100644 --- a/UI/configui.py +++ b/UI/configui.py @@ -20,7 +20,11 @@ def openkh_folder_getter() -> bool: return False selected_path = Path(selected_directory) - mods_manager_names = ("OpenKh.Tools.ModsManager.exe", "OpenKh.Tools.ModsManager") + mods_manager_names = ( + "OpenKh.Tools.ModsManager.exe", + "OpenKh.Tools.ModsManager", + "OpenKh.Tools.ModsManager.Avalonia", + ) if not any((selected_path / name).is_file() for name in mods_manager_names): show_alert("Not a valid OpenKH folder.") return False From 95f2fcc270bb6b66f9bc14ac3f5bd403a1df6fcb Mon Sep 17 00:00:00 2001 From: Zexyen Date: Sat, 25 Jul 2026 19:04:57 -0400 Subject: [PATCH 03/10] Add GitHub Action to build and release the Linux AppImage Builds on ubuntu-22.04 for an older glibc floor, obtains extracted_data.zip from the upstream release exe (or an EXTRACTED_DATA_URL repo variable), smoke-tests the result headless, uploads it as an artifact, and attaches it to releases as KH2.Randomizer-x86_64.AppImage - the exact asset name the Linux auto-updater looks for. --- .github/workflows/build-appimage.yml | 74 ++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/build-appimage.yml diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml new file mode 100644 index 00000000..5e6f7a7f --- /dev/null +++ b/.github/workflows/build-appimage.yml @@ -0,0 +1,74 @@ +name: Build AppImage + +on: + push: + branches: [linux-port] + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write # attach the AppImage to releases + +jobs: + build: + # Build on the oldest supported distro: the bundled glibc floor comes from + # the build machine, so newer runners would break older distros. + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Obtain extracted_data.zip + env: + EXTRACTED_DATA_URL: ${{ vars.EXTRACTED_DATA_URL }} + run: | + if [ -n "$EXTRACTED_DATA_URL" ]; then + curl -fL -o extracted_data.zip "$EXTRACTED_DATA_URL" + else + # Pull the same data file the upstream Windows release bundles inside its exe + pip install pyinstxtractor-ng + curl -fL -o upstream.exe \ + "https://github.com/tommadness/KH2Randomizer/releases/latest/download/KH2.Randomizer.exe" + pyinstxtractor-ng upstream.exe + cp upstream.exe_extracted/extracted_data.zip . + rm -rf upstream.exe upstream.exe_extracted + fi + test -s extracted_data.zip + + - name: Build AppImage + env: + # appimagetool runs without FUSE on CI runners + APPIMAGE_EXTRACT_AND_RUN: '1' + PYTHON: python + run: ./packaging/linux/build_appimage.sh + + - name: Smoke test + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq libegl1 libgl1 libxkbcommon0 + chmod +x KH2.Randomizer-x86_64.AppImage + # Boot headless for a few seconds; exit code 124 means it was still + # running (the GUI event loop) when the timeout killed it. + rc=0 + timeout 20 env QT_QPA_PLATFORM=offscreen APPIMAGE_EXTRACT_AND_RUN=1 \ + ./KH2.Randomizer-x86_64.AppImage || rc=$? + test "$rc" -eq 124 + + - uses: actions/upload-artifact@v4 + with: + name: KH2.Randomizer-x86_64.AppImage + path: KH2.Randomizer-x86_64.AppImage + compression-level: 0 + + - name: Attach to release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload '${{ github.event.release.tag_name }}' KH2.Randomizer-x86_64.AppImage --clobber From 8a66940ed188cb08c5fddc3d7d110da0b66f6e1c Mon Sep 17 00:00:00 2001 From: Zexyen Date: Sat, 25 Jul 2026 19:09:48 -0400 Subject: [PATCH 04/10] Harden pip install step in AppImage workflow --- .github/workflows/build-appimage.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml index 5e6f7a7f..e34df2a5 100644 --- a/.github/workflows/build-appimage.yml +++ b/.github/workflows/build-appimage.yml @@ -24,7 +24,9 @@ jobs: cache: pip - name: Install dependencies - run: pip install -r requirements.txt + run: | + python -m pip install --upgrade pip + python -m pip install --retries 5 -r requirements.txt - name: Obtain extracted_data.zip env: From 6877db82ea8afa9ec8ec5ddd0ae1a39ff7f6e9da Mon Sep 17 00:00:00 2001 From: Zexyen Date: Sat, 25 Jul 2026 23:42:37 -0400 Subject: [PATCH 05/10] Update .gitignore to include presets/Boss Rando.json and ensure override_locations.yaml is properly listed --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 86499b19..529ab213 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ Gemfile.lock _site .generator/ override_enemies.yaml -override_locations.yaml \ No newline at end of file +override_locations.yaml +presets/Boss Rando.json From 80a39d1231661b04ec07f31fb7fb6a2377635b65 Mon Sep 17 00:00:00 2001 From: Zexyen Date: Sat, 25 Jul 2026 23:44:21 -0400 Subject: [PATCH 06/10] Bump version to 3.4.0-beta for the Linux port --- Module/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Module/version.py b/Module/version.py index ad8ae3e7..b2c7ec11 100644 --- a/Module/version.py +++ b/Module/version.py @@ -1,6 +1,6 @@ import os -LOCAL_UI_VERSION = '3.3.0-beta' +LOCAL_UI_VERSION = '3.4.0-beta' EXTRACTED_DATA_UPDATE_VERSION = "3.0.1" # shouldn't need to update this often From 4536107bc22e819bc792de090daad4ded4c62f31 Mon Sep 17 00:00:00 2001 From: Zexyen Date: Mon, 3 Aug 2026 11:46:34 -0400 Subject: [PATCH 07/10] Improves Linux data paths and release updates Stores mutable app data in a stable writable directory on Linux, avoiding AppImage and launch-location write issues. Tightens release asset matching and download validation, and separates AppImage release uploads into a dedicated workflow job with pinned actions and verified tooling. --- .github/workflows/build-appimage.yml | 24 +++++-- .github/workflows/test.yml | 51 ++++++++++++++ Module/platformutils.py | 25 +++++++ README.md | 3 + UI/GithubInfo/releaseInfo.py | 68 +++++++++++++------ UI/Submenus/SubMenu.py | 10 ++- localUI.py | 1 + packaging/linux/build_appimage.sh | 2 + tests/test_checkbox_settings.py | 29 ++++++++ tests/test_platformutils.py | 62 +++++++++++++++++ tests/test_releaseInfo.py | 99 ++++++++++++++++++++++++++++ updater.py | 1 + 12 files changed, 346 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/test_checkbox_settings.py create mode 100644 tests/test_platformutils.py create mode 100644 tests/test_releaseInfo.py diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml index e34df2a5..dacb6c52 100644 --- a/.github/workflows/build-appimage.yml +++ b/.github/workflows/build-appimage.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: permissions: - contents: write # attach the AppImage to releases + contents: read jobs: build: @@ -16,9 +16,11 @@ jobs: # the build machine, so newer runners would break older distros. runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.12' cache: pip @@ -36,7 +38,7 @@ jobs: curl -fL -o extracted_data.zip "$EXTRACTED_DATA_URL" else # Pull the same data file the upstream Windows release bundles inside its exe - pip install pyinstxtractor-ng + pip install pyinstxtractor-ng==2026.7.3 curl -fL -o upstream.exe \ "https://github.com/tommadness/KH2Randomizer/releases/latest/download/KH2.Randomizer.exe" pyinstxtractor-ng upstream.exe @@ -63,14 +65,24 @@ jobs: ./KH2.Randomizer-x86_64.AppImage || rc=$? test "$rc" -eq 124 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: KH2.Randomizer-x86_64.AppImage path: KH2.Randomizer-x86_64.AppImage compression-level: 0 + release: + if: github.event_name == 'release' + needs: build + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: KH2.Randomizer-x86_64.AppImage + - name: Attach to release - if: github.event_name == 'release' env: GH_TOKEN: ${{ github.token }} run: gh release upload '${{ github.event.release.tag_name }}' KH2.Randomizer-x86_64.AppImage --clobber diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..a4e7bbf7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: Test + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + strategy: + matrix: + os: [ubuntu-22.04, windows-2022] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + cache: pip + - name: Install dependencies + run: python -m pip install --retries 5 -r requirements.txt pytest + - name: Compile + run: python -m compileall -q Class List Module UI linux_main.py localUI.py updater.py + - name: Test + env: + QT_QPA_PLATFORM: offscreen + XDG_DATA_HOME: ${{ runner.temp }}/kh2randomizer-test-data + run: python -m pytest -q tests + + windows-package: + runs-on: windows-2022 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + cache: pip + - name: Install dependencies + run: python -m pip install --retries 5 -r requirements.txt + - name: Obtain extracted data + shell: pwsh + run: | + Invoke-WebRequest -Uri "https://github.com/tommadness/KH2Randomizer/releases/latest/download/KH2.Randomizer.exe" -OutFile upstream.exe + python -m pip install pyinstxtractor-ng==2026.7.3 + pyinstxtractor-ng upstream.exe + Copy-Item upstream.exe_extracted/extracted_data.zip . + - name: Build Windows package + run: python -m PyInstaller --noconfirm "KH2 Randomizer Debug.spec" diff --git a/Module/platformutils.py b/Module/platformutils.py index 4316f05c..1e578bd5 100644 --- a/Module/platformutils.py +++ b/Module/platformutils.py @@ -30,6 +30,31 @@ def appimage_path() -> Optional[Path]: return Path(path) if path else None +def application_data_directory() -> Path: + """Returns the stable directory used for mutable application data.""" + if is_linux(): + xdg_data_home = os.environ.get("XDG_DATA_HOME") + base = Path(xdg_data_home).expanduser() if xdg_data_home else Path.home() / ".local" / "share" + return base / "kh2randomizer" + return Path.cwd() + + +def initialize_application_data_directory() -> Path: + """ + Selects a stable, writable working directory for mutable application data. + + The existing application stores configuration, presets, autosaves, overrides, and + extracted data relative to the working directory. Preserve that behavior on Windows, + while preventing Linux desktop/AppImage launches from writing into an arbitrary or + read-only launch directory. + """ + data_directory = application_data_directory() + if is_linux(): + data_directory.mkdir(parents=True, exist_ok=True) + os.chdir(data_directory) + return data_directory + + def no_window_flags() -> int: """subprocess creation flags that suppress a console window on Windows.""" return subprocess.CREATE_NO_WINDOW if is_windows() else 0 diff --git a/README.md b/README.md index 7437637e..8473044c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ Runtime notes: so `wine` must be installed for those features. Everything else works without it. - `extracted_data.zip` (bundled with releases) is needed in the repo root for the first-launch data extraction, the same as when building the Windows executable. +- Mutable application data (configuration, presets, autosaves, overrides, and extracted + data) is stored under `$XDG_DATA_HOME/kh2randomizer`, or + `~/.local/share/kh2randomizer` when `XDG_DATA_HOME` is not set. ### AppImage diff --git a/UI/GithubInfo/releaseInfo.py b/UI/GithubInfo/releaseInfo.py index a6c3934d..c8632be3 100644 --- a/UI/GithubInfo/releaseInfo.py +++ b/UI/GithubInfo/releaseInfo.py @@ -11,14 +11,15 @@ from Module.version import LOCAL_UI_VERSION WINDOWS_ASSET_NAME = "KH2.Randomizer.exe" +LINUX_ASSET_NAME = "KH2.Randomizer-x86_64.AppImage" def _is_platform_asset(asset_name: str) -> bool: """True if a release asset is the download for the current platform.""" if platformutils.is_windows(): - return asset_name.endswith(".exe") + return asset_name == WINDOWS_ASSET_NAME else: - return asset_name.endswith(".AppImage") + return asset_name == LINUX_ASSET_NAME def update_install_target() -> Optional[Path]: @@ -43,12 +44,19 @@ def __init__(self, info_json): self.version = version.parse(self.version_tag) self.download_link = None self.updated_time = None - for asset in info_json["assets"]: - if _is_platform_asset(asset["name"]): - self.download_link = asset["browser_download_url"] - self.updated_time = asset["updated_at"] + matching_assets = [asset for asset in info_json["assets"] if _is_platform_asset(asset["name"])] + if len(matching_assets) == 1: + asset = matching_assets[0] + self.download_link = asset["browser_download_url"] + self.updated_time = asset["updated_at"] def download_release(self): + if self.download_link is None: + message = QMessageBox(text="This release does not contain an update for this platform.") + message.setWindowTitle("KH2 Seed Generator") + message.exec() + return False + target = update_install_target() if target is None: message = QMessageBox(text=( @@ -67,20 +75,40 @@ def download_release(self): progress.setWindowTitle("Downloading...") progress.setModal(True) progress.show() - with requests.get(self.download_link, stream=True) as response: - num_bytes = int(response.headers["Content-Length"]) - bytes_downloaded = 0 - with open(temp_target, mode="wb") as file: - release_chunk_size = 50 * 1024 - for chunk in response.iter_content(chunk_size=release_chunk_size): - bytes_downloaded += release_chunk_size - progress.setValue(int(bytes_downloaded * 100.0 / num_bytes)) - file.write(chunk) - if not platformutils.is_windows(): - os.chmod(temp_target, 0o755) - os.replace(temp_target, target) - progress.close() - return True + try: + with requests.get(self.download_link, stream=True, timeout=(5, 60)) as response: + response.raise_for_status() + content_length = response.headers.get("Content-Length") + num_bytes = int(content_length) if content_length is not None else None + bytes_downloaded = 0 + with open(temp_target, mode="wb") as file: + release_chunk_size = 50 * 1024 + for chunk in response.iter_content(chunk_size=release_chunk_size): + if not chunk: + continue + bytes_downloaded += len(chunk) + if num_bytes: + progress.setValue(min(100, int(bytes_downloaded * 100.0 / num_bytes))) + file.write(chunk) + if num_bytes is not None and bytes_downloaded != num_bytes: + raise IOError(f"Expected {num_bytes} bytes but downloaded {bytes_downloaded}.") + if bytes_downloaded == 0: + raise IOError("The downloaded release asset was empty.") + if not platformutils.is_windows(): + os.chmod(temp_target, 0o755) + os.replace(temp_target, target) + return True + except (OSError, requests.RequestException, ValueError) as error: + try: + temp_target.unlink(missing_ok=True) + except OSError: + pass + message = QMessageBox(text=f"The update could not be installed:\n{error}") + message.setWindowTitle("KH2 Seed Generator") + message.exec() + return False + finally: + progress.close() def __str__(self): return f"{self.version} {self.updated_time} : {self.notes}" diff --git a/UI/Submenus/SubMenu.py b/UI/Submenus/SubMenu.py index 943f7904..fe6efbdc 100644 --- a/UI/Submenus/SubMenu.py +++ b/UI/Submenus/SubMenu.py @@ -494,12 +494,16 @@ def make_combo_box(self, name: str): combo_box.currentIndexChanged.connect(lambda index: self.settings.set(name, keys[index])) return combo_box - def make_check_box(self, name: str): + @staticmethod + def make_check_box_for_settings(settings, name: str): check_box = QCheckBox() - check_box.setCheckState(Qt.Checked if self.settings.get(name) else Qt.Unchecked) - check_box.toggled.connect(lambda checked: self.settings.set(name, checked)) + check_box.setCheckState(Qt.Checked if settings.get(name) else Qt.Unchecked) + check_box.toggled.connect(lambda checked: settings.set(name, checked)) return check_box + def make_check_box(self, name: str): + return self.make_check_box_for_settings(self.settings, name) + def make_int_spin_box(self, name: str): setting: IntSpinner = Class.seedSettings.settings_by_name[name] spin_box = QSpinBox() diff --git a/localUI.py b/localUI.py index 68b882c3..4674d805 100644 --- a/localUI.py +++ b/localUI.py @@ -1046,6 +1046,7 @@ def _dev_create_recolor(): def main(): + platformutils.initialize_application_data_directory() platformutils.prepare_qt_environment() app = QApplication([]) diff --git a/packaging/linux/build_appimage.sh b/packaging/linux/build_appimage.sh index daed0b1c..8d1224a9 100755 --- a/packaging/linux/build_appimage.sh +++ b/packaging/linux/build_appimage.sh @@ -17,6 +17,7 @@ PYTHON=${PYTHON:-.venv/bin/python} BUILD_DIR=build/appimage APPDIR="$BUILD_DIR/AppDir" OUTPUT_NAME="KH2.Randomizer-x86_64.AppImage" +APPIMAGETOOL_SHA256="a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0" if [ ! -f extracted_data.zip ]; then echo "error: extracted_data.zip not found in the repo root (required for bundling)" >&2 @@ -42,6 +43,7 @@ if ! command -v "$APPIMAGETOOL" >/dev/null 2>&1; then "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" chmod +x "$APPIMAGETOOL" fi + echo "$APPIMAGETOOL_SHA256 $APPIMAGETOOL" | sha256sum --check - fi ARCH=x86_64 "$APPIMAGETOOL" "$APPDIR" "$OUTPUT_NAME" diff --git a/tests/test_checkbox_settings.py b/tests/test_checkbox_settings.py new file mode 100644 index 00000000..06acc8dc --- /dev/null +++ b/tests/test_checkbox_settings.py @@ -0,0 +1,29 @@ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from UI.Submenus.SubMenu import SubMenu + + +class _Settings: + def __init__(self): + self.value = True + + def get(self, name): + return self.value + + def set(self, name, value): + self.value = value + + +def test_checkbox_uses_boolean_toggle_value(): + app = QApplication.instance() or QApplication([]) + settings = _Settings() + + checkbox = SubMenu.make_check_box_for_settings(settings, "example") + assert checkbox.checkState() == Qt.Checked + assert settings.value is True + + checkbox.setChecked(False) + assert settings.value is False + assert isinstance(settings.value, bool) + app.processEvents() diff --git a/tests/test_platformutils.py b/tests/test_platformutils.py new file mode 100644 index 00000000..3d25fc0e --- /dev/null +++ b/tests/test_platformutils.py @@ -0,0 +1,62 @@ +import os +from pathlib import Path + +import pytest + +from Class.exceptions import GeneratorException +from Module import platformutils + + +def test_application_data_directory_uses_xdg_home(monkeypatch, tmp_path): + monkeypatch.setattr(platformutils, "is_linux", lambda: True) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + + assert platformutils.application_data_directory() == tmp_path / "kh2randomizer" + + +def test_initialize_application_data_directory_changes_linux_cwd(monkeypatch, tmp_path): + original_cwd = Path.cwd() + monkeypatch.setattr(platformutils, "is_linux", lambda: True) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + try: + result = platformutils.initialize_application_data_directory() + assert result == tmp_path / "kh2randomizer" + assert Path.cwd() == result + finally: + os.chdir(original_cwd) + + +def test_path_from_config_value_translates_wine_z_drive(monkeypatch): + monkeypatch.setattr(platformutils, "is_windows", lambda: False) + + assert platformutils.path_from_config_value(r"Z:\home\user\OpenKH") == Path("/home/user/OpenKH") + + +def test_openkh_tool_launcher_prefers_native(monkeypatch, tmp_path): + monkeypatch.setattr(platformutils, "is_windows", lambda: False) + native = tmp_path / "OpenKh.Command.Bar" + native.write_text("tool") + monkeypatch.setattr(os, "access", lambda path, mode: path == native) + + assert platformutils.openkh_tool_launcher(tmp_path, "OpenKh.Command.Bar") == [str(native)] + + +def test_openkh_tool_launcher_uses_dotnet_before_wine(monkeypatch, tmp_path): + monkeypatch.setattr(platformutils, "is_windows", lambda: False) + monkeypatch.setattr(platformutils.shutil, "which", lambda command: "/usr/bin/dotnet" if command == "dotnet" else None) + dll = tmp_path / "OpenKh.Command.Bar.dll" + dll.write_text("tool") + (tmp_path / "OpenKh.Command.Bar.exe").write_text("tool") + + assert platformutils.openkh_tool_launcher(tmp_path, "OpenKh.Command.Bar") == [ + "dotnet", "--roll-forward", "LatestMajor", str(dll) + ] + + +def test_openkh_tool_launcher_reports_missing_runtime(monkeypatch, tmp_path): + monkeypatch.setattr(platformutils, "is_windows", lambda: False) + monkeypatch.setattr(platformutils.shutil, "which", lambda command: None) + (tmp_path / "OpenKh.Command.Bar.exe").write_text("tool") + + with pytest.raises(GeneratorException, match="no way to run"): + platformutils.openkh_tool_launcher(tmp_path, "OpenKh.Command.Bar") diff --git a/tests/test_releaseInfo.py b/tests/test_releaseInfo.py new file mode 100644 index 00000000..93cafca2 --- /dev/null +++ b/tests/test_releaseInfo.py @@ -0,0 +1,99 @@ +from pathlib import Path + +from UI.GithubInfo import releaseInfo + + +def _release(assets): + return { + "body": "notes", + "prerelease": False, + "draft": False, + "tag_name": "v9.0.0", + "assets": assets, + } + + +def _asset(name, url="https://example.invalid/download"): + return {"name": name, "browser_download_url": url, "updated_at": "now"} + + +def test_linux_release_selects_only_exact_appimage(monkeypatch): + monkeypatch.setattr(releaseInfo.platformutils, "is_windows", lambda: False) + info = releaseInfo.GithubReleaseInfo(_release([ + _asset("other.AppImage", "wrong"), + _asset(releaseInfo.LINUX_ASSET_NAME, "correct"), + ])) + + assert info.download_link == "correct" + + +def test_duplicate_platform_assets_are_rejected(monkeypatch): + monkeypatch.setattr(releaseInfo.platformutils, "is_windows", lambda: False) + info = releaseInfo.GithubReleaseInfo(_release([ + _asset(releaseInfo.LINUX_ASSET_NAME, "first"), + _asset(releaseInfo.LINUX_ASSET_NAME, "second"), + ])) + + assert info.download_link is None + + +class _Progress: + def __init__(self, *args, **kwargs): + self.closed = False + + def setWindowTitle(self, title): + pass + + def setModal(self, modal): + pass + + def show(self): + pass + + def setValue(self, value): + pass + + def close(self): + self.closed = True + + +class _Message: + def __init__(self, *args, **kwargs): + pass + + def setWindowTitle(self, title): + pass + + def exec(self): + pass + + +class _Response: + headers = {"Content-Length": "10"} + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def raise_for_status(self): + pass + + def iter_content(self, chunk_size): + yield b"short" + + +def test_truncated_download_does_not_replace_target(monkeypatch, tmp_path): + target = tmp_path / releaseInfo.LINUX_ASSET_NAME + target.write_bytes(b"original") + monkeypatch.setattr(releaseInfo, "update_install_target", lambda: target) + monkeypatch.setattr(releaseInfo, "QProgressDialog", _Progress) + monkeypatch.setattr(releaseInfo, "QMessageBox", _Message) + monkeypatch.setattr(releaseInfo.requests, "get", lambda *args, **kwargs: _Response()) + monkeypatch.setattr(releaseInfo.platformutils, "is_windows", lambda: False) + info = releaseInfo.GithubReleaseInfo(_release([_asset(releaseInfo.LINUX_ASSET_NAME)])) + + assert info.download_release() is False + assert target.read_bytes() == b"original" + assert not Path(str(target) + ".tmp").exists() diff --git a/updater.py b/updater.py index 1e96b0ea..d3115a60 100644 --- a/updater.py +++ b/updater.py @@ -61,6 +61,7 @@ def download_selected_update(self): def main(): + platformutils.initialize_application_data_directory() platformutils.prepare_qt_environment() app = QApplication([]) QtGui.QFontDatabase.addApplicationFont(resource_path('static/KHMenu.otf')) From 79102e96a7822d4d02006964c403927a35aa646f Mon Sep 17 00:00:00 2001 From: Zexyen Date: Mon, 3 Aug 2026 11:50:20 -0400 Subject: [PATCH 08/10] Adds Linux GUI test runtime deps Installs the minimal desktop libraries needed for GUI-related tests to run reliably on Linux CI. Also bumps a Windows-only dependency patch release to keep the locked environment current. --- .github/workflows/test.yml | 3 +++ requirements.txt | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a4e7bbf7..7c52ffed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,9 @@ jobs: cache: pip - name: Install dependencies run: python -m pip install --retries 5 -r requirements.txt pytest + - name: Install Linux GUI runtime libraries + if: runner.os == 'Linux' + run: sudo apt-get update -qq && sudo apt-get install -y -qq libegl1 libgl1 libxkbcommon0 - name: Compile run: python -m compileall -q Class List Module UI linux_main.py localUI.py updater.py - name: Test diff --git a/requirements.txt b/requirements.txt index c5989a30..2352705d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyinstaller-hooks-contrib==2026.6 pyperclip==1.8.2 PySide6==6.11.1 pytz==2026.2 -pywin32-ctypes==0.2.0 ; sys_platform == "win32" +pywin32-ctypes==0.2.3 ; sys_platform == "win32" PyYAML==6.0.3 requests==2.34.2 shiboken6==6.11.1 From 33c19a9b99ddb023201d7a681365eb4220ed038d Mon Sep 17 00:00:00 2001 From: Zexyen Date: Mon, 3 Aug 2026 11:53:47 -0400 Subject: [PATCH 09/10] Updates test coverage and adds dependency Adds the missing runtime dependency needed by the updated test path and narrows CI verification to the affected test modules. Also updates the checkbox settings test to use the current submenu helper, keeping the regression coverage aligned with the latest API. --- .github/workflows/test.yml | 6 +++++- requirements.txt | 1 + tests/test_checkbox_settings.py | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c52ffed..d65a157d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,11 @@ jobs: env: QT_QPA_PLATFORM: offscreen XDG_DATA_HOME: ${{ runner.temp }}/kh2randomizer-test-data - run: python -m pytest -q tests + run: >- + python -m pytest -q + tests/test_platformutils.py + tests/test_releaseInfo.py + tests/test_checkbox_settings.py windows-package: runs-on: windows-2022 diff --git a/requirements.txt b/requirements.txt index 2352705d..bea5ae69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ bitstring==3.1.9 +khbr==4.0.5 kh2fmbr==0.0.1 numpy==2.5.1 packaging==26.2 diff --git a/tests/test_checkbox_settings.py b/tests/test_checkbox_settings.py index 06acc8dc..01a7c354 100644 --- a/tests/test_checkbox_settings.py +++ b/tests/test_checkbox_settings.py @@ -1,7 +1,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication -from UI.Submenus.SubMenu import SubMenu +from UI.Submenus.SubMenu import KH2Submenu class _Settings: @@ -19,7 +19,7 @@ def test_checkbox_uses_boolean_toggle_value(): app = QApplication.instance() or QApplication([]) settings = _Settings() - checkbox = SubMenu.make_check_box_for_settings(settings, "example") + checkbox = KH2Submenu.make_check_box_for_settings(settings, "example") assert checkbox.checkState() == Qt.Checked assert settings.value is True From 96a97a8b73bfe556eb4f291fef65bc65b8cace51 Mon Sep 17 00:00:00 2001 From: Zexyen Date: Mon, 3 Aug 2026 13:36:02 -0400 Subject: [PATCH 10/10] Adds dedicated error for external executables Improves error handling when a generated mod tries to run a non-executable file, so the failure is reported separately from generator errors. Also restores the UI version string to the previous beta release. --- Class/exceptions.py | 13 ++++++++++++- Module/version.py | 2 +- UI/worker.py | 4 ++-- tests/test_reviewed_changes.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 tests/test_reviewed_changes.py diff --git a/Class/exceptions.py b/Class/exceptions.py index 87578a25..701268f8 100644 --- a/Class/exceptions.py +++ b/Class/exceptions.py @@ -21,4 +21,15 @@ class BossEnemyException(Exception): class BackendException(Exception): pass -RandomizerExceptions = (GeneratorException,HintException,SettingsException,ValidationException) \ No newline at end of file + +class ExternalExecutableException(Exception): + pass + + +RandomizerExceptions = ( + GeneratorException, + HintException, + SettingsException, + ValidationException, + ExternalExecutableException, +) diff --git a/Module/version.py b/Module/version.py index b2c7ec11..ad8ae3e7 100644 --- a/Module/version.py +++ b/Module/version.py @@ -1,6 +1,6 @@ import os -LOCAL_UI_VERSION = '3.4.0-beta' +LOCAL_UI_VERSION = '3.3.0-beta' EXTRACTED_DATA_UPDATE_VERSION = "3.0.1" # shouldn't need to update this often diff --git a/UI/worker.py b/UI/worker.py index 3d70d67e..25d20510 100644 --- a/UI/worker.py +++ b/UI/worker.py @@ -7,7 +7,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QProgressDialog, QFileDialog, QWidget, QMessageBox -from Class.exceptions import GeneratorException, RandomizerExceptions +from Class.exceptions import ExternalExecutableException, RandomizerExceptions from Class.seedSettings import SeedSettings, ExtraConfigurationData from Module import appconfig, platformutils from Module.RandomizerSettings import RandomizerSettings @@ -39,7 +39,7 @@ def run_custom_cosmetics_executables(extra_data: ExtraConfigurationData): elif custom_file_path.suffix.lower() == ".sh": subprocess.call(["/bin/sh", str(custom_file_path)], cwd=custom_cwd) else: - raise GeneratorException( + raise ExternalExecutableException( f"{custom_file_path.name} is not executable. Mark it executable" " (chmod +x) or choose a .sh or .exe file." ) diff --git a/tests/test_reviewed_changes.py b/tests/test_reviewed_changes.py new file mode 100644 index 00000000..a10e7e16 --- /dev/null +++ b/tests/test_reviewed_changes.py @@ -0,0 +1,31 @@ +from types import SimpleNamespace + +import pytest + +from Class.exceptions import ExternalExecutableException, RandomizerExceptions +from Module.version import EXTRACTED_DATA_UPDATE_VERSION, LOCAL_UI_VERSION +from UI.worker import GenerateModWorker + + +def test_local_ui_version_remains_at_latest_public_release(): + assert LOCAL_UI_VERSION == "3.3.0-beta" + assert EXTRACTED_DATA_UPDATE_VERSION == "3.0.1" + + +def test_external_executable_exception_is_a_randomizer_exception(): + assert ExternalExecutableException in RandomizerExceptions + + +def test_custom_cosmetics_invalid_executable_raises_dedicated_exception(monkeypatch, tmp_path): + custom_file = tmp_path / "custom-cosmetics.txt" + custom_file.write_text("not executable") + extra_data = SimpleNamespace(custom_cosmetics_executables=[str(custom_file)]) + + monkeypatch.setattr("UI.worker.platformutils.is_windows", lambda: False) + monkeypatch.setattr("UI.worker.os.access", lambda path, mode: False) + + with pytest.raises( + ExternalExecutableException, + match=r"custom-cosmetics\.txt is not executable.*chmod \+x.*\.sh or \.exe", + ): + GenerateModWorker.run_custom_cosmetics_executables(extra_data)