├── .gitignore ├── COPYING ├── README.md ├── docs ├── fixing_game_metadata.md ├── icons │ ├── flatpak.svg │ ├── flatpak_mono.svg │ ├── lutris.svg │ ├── lutris_mono.svg │ ├── snapcraft.svg │ ├── steam.svg │ ├── tux.svg │ └── tux_mono.svg ├── mockup1.png ├── mockup2.png ├── mockup3.png ├── mockup4.png ├── mockup5-browsing-view.png ├── mockup6-scanning-view.png ├── mockup7-selection-view.png ├── mockup8-life-cycle-view.png └── ui_design_process.md └── gali ├── data ├── com.github.geoffreycoulaud.gali.appdata.xml.in ├── com.github.geoffreycoulaud.gali.desktop.in ├── com.github.geoffreycoulaud.gali.gschema.xml ├── icons │ ├── meson.build │ └── scalable │ │ └── com.github.geoffreycoulaud.gali.svg └── meson.build ├── meson.build ├── po ├── LINGUAS ├── POTFILES └── meson.build └── src ├── __init__.py ├── gali.gresource.xml ├── gali.in ├── game_wrapper_process.py ├── launcher.py ├── library.py ├── main.py ├── meson.build ├── singletons.py ├── sources ├── __init__.py ├── all_sources.py ├── cemu │ ├── __init__.py │ ├── cemu_game.py │ ├── cemu_source.py │ └── lutris │ │ ├── __init__.py │ │ ├── abc_cemu_lutris_game.py │ │ ├── cemu_lutris_game.py │ │ ├── cemu_lutris_source.py │ │ └── cemu_lutris_startup_chain.py ├── citra │ ├── __init__.py │ ├── citra_game.py │ ├── citra_source.py │ ├── citra_startup_chain.py │ ├── flatpak │ │ ├── __init__.py │ │ ├── citra_flatpak_game.py │ │ ├── citra_flatpak_source.py │ │ └── citra_flatpak_startup_chain.py │ └── native │ │ ├── __init__.py │ │ ├── citra_native_game.py │ │ ├── citra_native_source.py │ │ └── citra_native_startup_chain.py ├── cli_startup_chain.py ├── desktop │ ├── __init__.py │ ├── abc_desktop_game.py │ ├── desktop_game.py │ ├── desktop_source.py │ └── desktop_startup_chain.py ├── dolphin │ ├── __init__.py │ ├── dolphin_game.py │ ├── dolphin_source.py │ ├── dolphin_startup_chain.py │ ├── flatpak │ │ ├── __init__.py │ │ ├── dolphin_flatpak_game.py │ │ ├── dolphin_flatpak_source.py │ │ └── dolphin_flatpak_startup_chain.py │ └── native │ │ ├── __init__.py │ │ ├── dolphin_native_game.py │ │ ├── dolphin_native_source.py │ │ └── dolphin_native_startup_chain.py ├── emulation_game.py ├── emulation_source.py ├── file_dependent_source.py ├── game.py ├── game_dir.py ├── generic_game.py ├── heroic │ ├── __init__.py │ ├── flatpak │ │ ├── __init__.py │ │ └── heroic_flatpak_source.py │ ├── heroic_game.py │ ├── heroic_source.py │ ├── heroic_xdg_game.py │ ├── heroic_xdg_startup_chain.py │ └── native │ │ ├── __init__.py │ │ └── heroic_native_source.py ├── itch │ ├── __init__.py │ ├── itch_game.py │ ├── itch_source.py │ ├── itch_startup_chain.py │ └── native │ │ ├── itch_java_startup_chain.py │ │ ├── itch_linux_startup_chain.py │ │ ├── itch_native_game.py │ │ ├── itch_native_source.py │ │ └── itch_script_startup_chain.py ├── legendary │ ├── __init__.py │ ├── legendary_game.py │ ├── legendary_source.py │ └── native │ │ ├── __init__.py │ │ ├── legendary_native_game.py │ │ ├── legendary_native_source.py │ │ └── legendary_native_startup_chain.py ├── lutris │ ├── __init__.py │ ├── lutris_game.py │ ├── lutris_source.py │ └── native │ │ ├── __init__.py │ │ ├── lutris_native_game.py │ │ ├── lutris_native_source.py │ │ └── lutris_native_startup_chain.py ├── ppsspp │ ├── __init__.py │ ├── flatpak │ │ ├── __init__.py │ │ ├── ppsspp_flatpak_game.py │ │ ├── ppsspp_flatpak_source.py │ │ └── ppsspp_flatpak_startup_chain.py │ ├── native │ │ ├── __init__.py │ │ ├── ppsspp_native_game.py │ │ ├── ppsspp_native_source.py │ │ └── ppsspp_native_startup_chain.py │ ├── ppsspp_game.py │ ├── ppsspp_source.py │ └── ppsspp_startup_chain.py ├── retroarch │ ├── __init__.py │ ├── flatpak │ │ ├── __init__.py │ │ ├── retroarch_flatpak_game.py │ │ ├── retroarch_flatpak_source.py │ │ └── retroarch_flatpak_startup_chain.py │ ├── native │ │ ├── __init__.py │ │ ├── retroarch_native_game.py │ │ ├── retroarch_native_source.py │ │ └── retroarch_native_startup_chain.py │ ├── retroarch_game.py │ ├── retroarch_source.py │ └── retroarch_startup_chain.py ├── scannable.py ├── script_startup_chain.py ├── source.py ├── startable.py ├── startup_chain.py ├── steam │ ├── __init__.py │ ├── flatpak │ │ ├── __init__.py │ │ └── steam_flatpak_source.py │ ├── native │ │ ├── __init__.py │ │ └── steam_native_source.py │ ├── steam_game.py │ ├── steam_source.py │ ├── steam_xdg_game.py │ └── steam_xdg_startup_chain.py ├── stemmed_cli_startup_chain.py └── yuzu │ ├── __init__.py │ ├── flatpak │ ├── __init__.py │ ├── yuzu_flatpak_game.py │ ├── yuzu_flatpak_source.py │ └── yuzu_flatpak_startup_chain.py │ ├── native │ ├── __init__.py │ ├── yuzu_native_game.py │ ├── yuzu_native_source.py │ └── yuzu_native_startup_chain.py │ ├── yuzu_game.py │ ├── yuzu_source.py │ └── yuzu_startup_chain.py ├── ui ├── __init__.py ├── application.py ├── application_window.py ├── filter_popover.py ├── game_life_cycle_controls.py ├── games_details.py ├── games_view.py └── templates │ ├── about_window.ui │ ├── application_window.ui │ ├── game_details.ui │ ├── game_life_cycle_controls.ui │ └── kill_game_confirm_dialog.ui └── utils ├── __init__.py ├── cfg_parser.py ├── deep_find_files.py ├── explicit_config_parser.py ├── locations.py ├── lutris_export_script.py ├── prepare_filename.py ├── rpx_metadata.py ├── sandbox.py └── wine_path.py /.gitignore: -------------------------------------------------------------------------------- 1 | build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | gali's logo 2 | 3 | # Gali 4 | 5 | View and start all your games from a single launcher 6 | 7 | ## Supported game sources 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 32 | 33 | 34 | 35 | 36 | 40 | 41 | 42 | 46 | 47 | 48 | 49 | 50 | 53 | 54 | 55 | 58 | 59 | 60 | 61 | 62 | 66 | 67 | 68 | 72 | 73 | 74 | 75 | 76 | 80 | 81 | 82 | 86 | 87 | 88 | 89 | 90 | 93 | 94 | 95 | 98 | 99 | 100 | 101 | 102 | 105 | 106 | 107 | 110 | 111 | 112 | 113 | 114 | 117 | 118 | 119 | 122 | 123 | 124 | 125 | 126 | 130 | 131 | 132 | 136 | 137 | 138 | 139 | 140 | 144 | 145 | 146 | 150 | 151 | 152 | 153 | 154 | 158 | 159 | 160 | 164 | 165 | 166 | 167 | 168 | 172 | 173 | 174 | 178 | 179 | 180 |
StatusVariantsNameDescriptionLinks
24 | Lutris 25 | CemuNintendo Wii U emulator 29 | Website | 30 | Lutris 31 |
✅ (2) 37 | Native 38 | Flatpak 39 | CitraNintendo 3DS emulator 43 | Website | 44 | Flathub 45 |
✅ (3) 51 | Native 52 | Desktop entriesRegular linux desktop entries 56 | Specification 57 |
63 | Native 64 | Flatpak 65 | DolphinNintendo Wii / GameCube emulator 69 | Website | 70 | Flathub 71 |
✅ (1) 77 | Native 78 | Flatpak 79 | HeroicFOSS Epic Games Launcher alternative (GUI) 83 | Website | 84 | Flathub 85 |
91 | Native 92 | ItchInstall, update and play indie games 96 | Website 97 |
✅ (1) 103 | Native 104 | LegendaryFOSS Epic Games Launcher alternative (CLI) 108 | Repository 109 |
115 | Native 116 | LutrisOpen Source gaming platform for Linux 120 | Website 121 |
127 | Native 128 | Flatpak 129 | PPSSPPSony PSP emulator 133 | Website | 134 | Flathub 135 |
141 | Native 142 | Flatpak 143 | RetroarchFrontend for the libretro API 147 | Website | 148 | Flathub 149 |
✅ (1) 155 | Native 156 | Flatpak 157 | SteamPC games store 161 | Website | 162 | Flathub 163 |
✅ (2) 169 | Native 170 | Flatpak 171 | YuzuNintendo Switch emulator 175 | Website | 176 | Flathub 177 |
181 | 182 | 1. **Steam**, **Legendary** and **Heroic** only allow starting games, not stopping or killing them 183 | 2. **Citra** and **Yuzu** installed games are not scanned (only roms are scanned) 184 | 3. Not available in Flatpak. 185 | In flatpak's sandbox it is not possible to get desktop entries from `/var/lib/flatpak/exports/share`, so none of the system-wide flatpak desktop entries are scanned. See [xdg-desktop-portal#809](https://github.com/flatpak/xdg-desktop-portal/issues/809) for a possible solution. 186 | 187 | ### What are these "variants" ? 188 | Some of the supported game sources can be distributed using multiple methods. In that case, the underlying logic is the same but the place where the games are stored and how they are launched may change. 189 | 190 | For example, *Retroarch* is distributed in all of these formats: 191 | - Standalone, installed via your distribution's package manager 192 | - Snap, installed from Snapcraft 193 | - Flatpak, installed from Flathub 194 | - Appimage, downloaded manually 195 | - Steam, installed from Steam's client 196 | - Itch.io, installed from Itch.io's website or client 197 | 198 | It's still the same source under the hood, so all of these are **variants** of the standalone version. 199 | 200 | ## Vision 201 | 202 | Gali is inspired by the Unix philosophy, "Do one thing and do it well". We diverge slightly by doing *a few* things : 203 | 204 | * Viewing games from a maximum of (local) sources 205 | * Starting all games normally 206 | * When possible, stopping started games 207 | 208 | This means that every other task concerning games is left to the game sources. This means that Gali will not be able to install, move, uninstall, manage games. 209 | Its goal is not to become the next Steam or Itch. Gali is only a frontend. 210 | 211 | Everything is done to make adding new sources as simple as possible. 212 | 213 | ## Installation 214 | 215 | **Gali is development. Please be patient !** 216 | 217 | ### Building from source 218 | 219 | The build directory can be changed at will, but a `build` subdir of the repository is recommended. 220 | 221 | ```sh 222 | git clone https://github.com/GeoffreyCoulaud/gali.git 223 | cd gali/gali 224 | meson setup ../build && cd ../build 225 | meson compile 226 | meson install 227 | ``` 228 | 229 | Then, simply start in a terminal 230 | 231 | ```sh 232 | gali 233 | ``` 234 | 235 | ## TODO 236 | 237 | * Continue UI work 238 | * Better documentation (docstrings, wiki) 239 | * Decouple scanning from the main thread 240 | * Add Marie's new icon for Gali 241 | * Add a preference panel 242 | * Save and load user preferences 243 | * Sources enhancements 244 | * Scan Dolphin cached games 245 | * Differenciate between Gamecube and Wii games 246 | * Scan installed games in Yuzu and Citra 247 | * New sources 248 | * Bottles 249 | * itch.io 250 | * Decaf 251 | * Ryujinx 252 | * (Linux native) Cemu 253 | * (in Steam) Retroarch 254 | * (in Lutris) Origin 255 | * (in Lutris) Battle_net 256 | * (in Lutris) Uplay 257 | * (in Lutris) Teknoparrot 258 | -------------------------------------------------------------------------------- /docs/fixing_game_metadata.md: -------------------------------------------------------------------------------- 1 | # Fixing game metadata 2 | ## The problem 3 | For frontends like this one it just isn't enough to get a ROM file that can be played. 4 | This app's goal is to *see and play* your library, to be able to choose a game to play for a chill session or with friends. 5 | The games are the star of the show, not any software around them. 6 | (Though, you should support projects you like if you have the means !) 7 | 8 | This means multiple things must be extracted from a game file name 9 | 1. Original name, 10 | 2. Localized names, 11 | 3. Images (icon, box art, banners...) 12 | 4. Other info (publisher, developer, release year...), 13 | 14 | Note that for this project points 1-3 are mandatory in my opinion. 15 | 16 | ## Possible solutions 17 | * __crc32 based recognition__, useful only for known good ROMs. 18 | This is the preferred option in most cases because most of the ROMs people have are the "good" ones, 19 | and it's insanely fast to get this value for most files compared to other identification methods. 20 | 21 | * __filename based recognition__, useful for well-named ROMs. 22 | This is for "the rest", bad dumps, translations, hacks, compressed ROMs or a brand new format 23 | that the emulator can play but the good roms database doesn't have yet. 24 | 25 | * __content based recognition__, useful for unpacked ROMs. 26 | This is the best case scenario, we don't have any guess work to do, the game provides its metadata. 27 | 28 | However this comes with the cost of having to rely on a trusted game database. 29 | This is fine in itself, but rubs me in the wrong way in the case of hash based recognition (which is the way retroarch does it). 30 | It's just not reliable enough. I want a solution that is **not necessarily fast** but is **reliable** even with uncommon / bad data 31 | and **accurate** enough to not produce weird results. -------------------------------------------------------------------------------- /docs/icons/flatpak.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 39 | 41 | 45 | 49 | 54 | 58 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /docs/icons/flatpak_mono.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 35 | 39 | 42 | 47 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /docs/icons/lutris.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 34 | 36 | 38 | 40 | 44 | 48 | 49 | 51 | 55 | 59 | 60 | 62 | 66 | 70 | 71 | 81 | 90 | 100 | 108 | 118 | 127 | 137 | 145 | 153 | 154 | 157 | 160 | 164 | 168 | 172 | 176 | 180 | 187 | 194 | 198 | 202 | 206 | 207 | 208 | 209 | -------------------------------------------------------------------------------- /docs/icons/lutris_mono.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/icons/snapcraft.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 35 | 42 | 44 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /docs/icons/steam.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 36 | 38 | 41 | 44 | 45 | 46 | 50 | 53 | 56 | 59 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /docs/mockup1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/docs/mockup1.png -------------------------------------------------------------------------------- /docs/mockup2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/docs/mockup2.png -------------------------------------------------------------------------------- /docs/mockup3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/docs/mockup3.png -------------------------------------------------------------------------------- /docs/mockup4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/docs/mockup4.png -------------------------------------------------------------------------------- /docs/mockup5-browsing-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/docs/mockup5-browsing-view.png -------------------------------------------------------------------------------- /docs/mockup6-scanning-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/docs/mockup6-scanning-view.png -------------------------------------------------------------------------------- /docs/mockup7-selection-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/docs/mockup7-selection-view.png -------------------------------------------------------------------------------- /docs/mockup8-life-cycle-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/docs/mockup8-life-cycle-view.png -------------------------------------------------------------------------------- /docs/ui_design_process.md: -------------------------------------------------------------------------------- 1 | # Gali's UI 2 | 3 | ## July of 2022, Migration 4 | 5 | As you can see, the project has migrated to Python instead of Node or GJS. 6 | I realized that basing a GUI program on an underused slate is not the best idea... 7 | 8 | Python is opten used for Gtk apps, and the PyGobject bindings are well-liked by the community. 9 | Rust users will be mad, but I'm not ready to move to it yet. 10 | 11 | In the following month, another contributor will join me on this project on the UI side. 12 | Expect new UI mockups 😄 ! 13 | 14 | ## May of 2022, New ideas 15 | 16 | During the long hiatus, I've taken time to work on other projects and reflect on Gali's architecture. I am not satisfied with any of the UI solutions used at the moment. Node-gtk is not mature enough, electron is bloated and all of these are basically using a screwdriver to plant a nail. 17 | 18 | I've made the mistake of treating Gali as a monolith, but it really needs to be split. My idea as of now is : 19 | 20 | - "Server", starts the UI, initiates scans, communicates with subprocesses. 21 | - "Database", storing the scanned games data 22 | - "UI", responsible for, well, the UI 23 | - "Scanner", responsible for scanning for games 24 | 25 | The UI just can't store all the data at once, **pagination is mandatory**. 26 | Also, the OOP based architecture is flawed in this case. All of the scanned games need to be serializable. 27 | Practically, games need to be composed of **only data**, not methods. 28 | 29 | Ultimately, I really want to use GTK, so the project will likely migrate to GJS. 30 | 31 | ## Second mockups 32 | This time I used Figma. The design file is [publicly available here](https://www.figma.com/file/YcTUGVEvarxrgpq01VkieN/Gali-Second-mockups?node-id=0%3A1). 33 | These mockups are higher fidelity, but not final yet. My goal with this revision 34 | is to start defining a system of app states. 35 | 36 |
37 | show images 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
Browsing viewScanning view
Selection viewLife cycle view
56 |
57 | 58 | ## First mockups 59 | These images were made to give a rough idea of what the UI should look like. 60 | I used [Pencil](https://pencil.evolus.vn/) to make these mockups, although I 61 | don't recommend using it since the project seems to be inactive for quite some 62 | months. 63 | I took inspiration from [Lutris](https://github.com/lutris/lutris). 64 | 65 |
66 | show images 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 |
Games grid, default viewScanning view, replacing the default view
Game info popover with a start buttonGame info with a stop and kill button
85 |
-------------------------------------------------------------------------------- /gali/data/com.github.geoffreycoulaud.gali.appdata.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.github.geoffreycoulaud.gali.desktop 4 | CC0-1.0 5 | GPL-3.0-or-later 6 | 7 |

No description

8 |
9 |
10 | -------------------------------------------------------------------------------- /gali/data/com.github.geoffreycoulaud.gali.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=gali 3 | Exec=gali 4 | Icon=com.github.geoffreycoulaud.gali 5 | Terminal=false 6 | Type=Application 7 | Categories=GTK; 8 | StartupNotify=true 9 | -------------------------------------------------------------------------------- /gali/data/com.github.geoffreycoulaud.gali.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gali/data/icons/meson.build: -------------------------------------------------------------------------------- 1 | install_data( 2 | [ 3 | 'scalable/com.github.geoffreycoulaud.gali.svg', 4 | # TODO enable when we have a dev version 5 | #'scalable/com.github.geoffreycoulaud.gali.Devel.svg', 6 | ], 7 | install_dir: datadir / 'icons/hicolor/scalable/apps', 8 | ) 9 | 10 | # TODO enable when we have a symbolic variant 11 | # install_data( 12 | # [ 13 | # 'symbolic/com.github.geoffreycoulaud.gali-symbolic.svg', 14 | # ] 15 | # install_dir: datadir / 'icons/hicolor/symbolic/apps', 16 | # ) -------------------------------------------------------------------------------- /gali/data/meson.build: -------------------------------------------------------------------------------- 1 | subdir('icons') 2 | 3 | i18n = import('i18n') 4 | 5 | desktop_file = i18n.merge_file( 6 | input: 'com.github.geoffreycoulaud.gali.desktop.in', 7 | output: 'com.github.geoffreycoulaud.gali.desktop', 8 | type: 'desktop', 9 | po_dir: '../po', 10 | install: true, 11 | install_dir: join_paths(get_option('datadir'), 'applications') 12 | ) 13 | 14 | desktop_utils = find_program('desktop-file-validate', required: false) 15 | if desktop_utils.found() 16 | test('Validate desktop file', desktop_utils, 17 | args: [desktop_file] 18 | ) 19 | endif 20 | 21 | appstream_file = i18n.merge_file( 22 | input: 'com.github.geoffreycoulaud.gali.appdata.xml.in', 23 | output: 'com.github.geoffreycoulaud.gali.appdata.xml', 24 | po_dir: '../po', 25 | install: true, 26 | install_dir: join_paths(get_option('datadir'), 'appdata') 27 | ) 28 | 29 | appstream_util = find_program('appstream-util', required: false) 30 | if appstream_util.found() 31 | test('Validate appstream file', appstream_util, 32 | args: ['validate', appstream_file] 33 | ) 34 | endif 35 | 36 | install_data('com.github.geoffreycoulaud.gali.gschema.xml', 37 | install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas') 38 | ) 39 | 40 | compile_schemas = find_program('glib-compile-schemas', required: false) 41 | if compile_schemas.found() 42 | test('Validate schema file', compile_schemas, 43 | args: ['--strict', '--dry-run', meson.current_source_dir()] 44 | ) 45 | endif 46 | -------------------------------------------------------------------------------- /gali/meson.build: -------------------------------------------------------------------------------- 1 | project( 2 | 'gali', 3 | version: '0.1.0', 4 | meson_version: '>= 0.59.0', 5 | default_options: [ 6 | 'warning_level=2', 7 | 'werror=false', 8 | ], 9 | ) 10 | 11 | datadir = join_paths(get_option('prefix'), get_option('datadir')) 12 | pkgdatadir = join_paths(datadir, meson.project_name()) 13 | moduledir = join_paths(pkgdatadir, 'gali') 14 | 15 | subdir('data') 16 | subdir('src') 17 | subdir('po') 18 | 19 | gnome = import('gnome') 20 | gnome.post_install( 21 | glib_compile_schemas: true, 22 | gtk_update_icon_cache: true, 23 | update_desktop_database: true, 24 | ) 25 | -------------------------------------------------------------------------------- /gali/po/LINGUAS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/po/LINGUAS -------------------------------------------------------------------------------- /gali/po/POTFILES: -------------------------------------------------------------------------------- 1 | data/com.github.geoffreycoulaud.gali.desktop.in 2 | data/com.github.geoffreycoulaud.gali.appdata.xml.in 3 | data/com.github.geoffreycoulaud.gali.gschema.xml 4 | src/window.ui 5 | src/main.py 6 | src/window.py 7 | 8 | -------------------------------------------------------------------------------- /gali/po/meson.build: -------------------------------------------------------------------------------- 1 | i18n = import('i18n') 2 | 3 | i18n.gettext('gali', preset: 'glib') 4 | -------------------------------------------------------------------------------- /gali/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/__init__.py -------------------------------------------------------------------------------- /gali/src/gali.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ui/templates/application_window.ui 5 | ui/templates/about_window.ui 6 | ui/templates/game_details.ui 7 | ui/templates/game_life_cycle_controls.ui 8 | ui/templates/kill_game_confirm_dialog.ui 9 | 10 | 11 | -------------------------------------------------------------------------------- /gali/src/gali.in: -------------------------------------------------------------------------------- 1 | #!@PYTHON@ 2 | 3 | import os 4 | import sys 5 | import signal 6 | import locale 7 | import gettext 8 | 9 | pkgdatadir = "@pkgdatadir@" 10 | localedir = "@localedir@" 11 | 12 | sys.path.insert(1, pkgdatadir) 13 | signal.signal(signal.SIGINT, signal.SIG_DFL) 14 | locale.bindtextdomain("gali", localedir) 15 | locale.textdomain("gali") 16 | gettext.install("gali", localedir) 17 | 18 | if __name__ == "__main__": 19 | import gi 20 | 21 | from gi.repository import Gio 22 | resource = Gio.Resource.load(os.path.join(pkgdatadir, "gali.gresource")) 23 | resource._register() 24 | 25 | from gali.main import main 26 | sys.exit(main()) 27 | -------------------------------------------------------------------------------- /gali/src/game_wrapper_process.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pickle 3 | from signal import signal, SIGTERM 4 | 5 | from gali.sources.startup_chain import StartupChain 6 | 7 | 8 | class StartupChainRunner(): 9 | """Class in charge of running the startup chain 10 | * Handles SIGTERM by running cleanup before exiting""" 11 | 12 | startup_chain: StartupChain 13 | 14 | def __init__(self, startup_chain: StartupChain) -> None: 15 | self.startup_chain = startup_chain 16 | 17 | def handle_sigterm(self, signum, frame) -> None: 18 | """Handle force terminating gracefully (cleanup)""" 19 | self.startup_chain.cleanup() 20 | sys.exit(0) 21 | 22 | def run(self) -> None: 23 | """Run the startup chain""" 24 | signal(SIGTERM, self.handle_sigterm) 25 | self.startup_chain.prepare() 26 | self.startup_chain.start() 27 | self.startup_chain.cleanup() 28 | sys.exit(0) 29 | 30 | 31 | def main(): 32 | startup_chain: StartupChain = pickle.load(sys.stdin.buffer) 33 | runner = StartupChainRunner(startup_chain) 34 | runner.run() 35 | 36 | if __name__ == "__main__": 37 | main() 38 | 39 | -------------------------------------------------------------------------------- /gali/src/launcher.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import inspect 3 | import sys 4 | from gi.repository import GObject 5 | from os import killpg, pathsep, environ 6 | from signal import SIGTERM, SIGKILL 7 | from subprocess import Popen, PIPE 8 | 9 | from gali.sources.startable import Startable 10 | from gali.game_wrapper_process import StartupChainRunner 11 | 12 | 13 | class GameRunningError(Exception): 14 | """Error raised when trying to change the launcher's game while it is running""" 15 | pass 16 | 17 | 18 | class GameNotSetError(Exception): 19 | """Error raised when trying to start the launcher when no game is set""" 20 | pass 21 | 22 | 23 | class NoStartupChainError(Exception): 24 | """Error raised when trying to start the launcher when the game has no startup chain""" 25 | pass 26 | 27 | 28 | class GameNotRunningError(Exception): 29 | """Error raised when trying to send a signal when the process is not started""" 30 | pass 31 | 32 | 33 | class Launcher(GObject.Object): 34 | """Singleton class representing a game launcher 35 | * Handles starting, stopping or killing a game""" 36 | 37 | __gtype_name__ = "GaliLauncher" 38 | 39 | game: Startable|None = None 40 | process: Popen|None = None 41 | 42 | def __init__(self) -> None: 43 | super().__init__() 44 | 45 | def is_running(self) -> bool: 46 | """Get the launcher running status""" 47 | if (self.game is None) or (self.process is None): return False 48 | return self.process.poll() is None 49 | 50 | def set_game(self, game: Startable): 51 | """Set the game for the launcher 52 | * Can raise GameRunningError if trying to change game when already running""" 53 | if self.is_running(): raise GameRunningError() 54 | self.game = game 55 | 56 | def start(self): 57 | """Start the set game. The resulting subprocess has its own process group. 58 | * Can raise GameNotSetError if no game is set 59 | * Can raise NoStartupChainError if game has no startup chain""" 60 | 61 | if self.is_running(): raise GameRunningError() 62 | if self.game is None: raise GameNotSetError() 63 | 64 | # TODO remove when choosing startup chain is implemented (sc passed as an argument) 65 | if len(self.game.startup_chains) == 0: raise NoStartupChainError() 66 | startup_chain_class = self.game.startup_chains[0] 67 | 68 | # TODO Remove when choosing startup chain options is implemented (options passed as an argument) 69 | options = dict() 70 | 71 | # Start game in a subprocess 72 | # This is important for several reasons: 73 | # - the UI must not hang 74 | # - terminating the game terminates its subprocesses 75 | # - exiting the launcher must not exit the game 76 | module = inspect.getabsfile(StartupChainRunner) 77 | process_args = [sys.executable, module] 78 | process_env = environ.copy() 79 | process_env["PYTHONPATH"] = pathsep.join(sys.path) 80 | self.process = Popen( 81 | args=process_args, 82 | env=process_env, 83 | start_new_session=True, 84 | stdin=PIPE 85 | ) 86 | 87 | # Pass data to subprocess 88 | startup_chain = startup_chain_class(game=self.game, options=options) 89 | pickle.dump(startup_chain, self.process.stdin) 90 | self.process.stdin.close() 91 | 92 | def terminate(self, force: bool = False) -> None: 93 | """Stop the running game 94 | * Setting force=True can incur data loss. Use at your own risk""" 95 | # TODO doesn't work in flatpak sandbox : Since games are run on the host and not in a sandbox, we can't send a signal to them 96 | if not self.is_running(): return 97 | signal = SIGKILL if force else SIGTERM 98 | killpg(self.process.pid, signal) -------------------------------------------------------------------------------- /gali/src/library.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from traceback import print_tb 3 | from gi.repository import Gio, GObject, Gtk 4 | from typing import Iterable 5 | 6 | from gali.sources.source import Source 7 | from gali.sources.all_sources import all_sources 8 | from gali.sources.generic_game import GenericGame 9 | 10 | 11 | class GameGObject(GObject.GObject): 12 | """A GObject wrapper around Gali games for use with GTK""" 13 | __gtype_name__ = "GameGObject" 14 | 15 | game: GenericGame 16 | 17 | def __init__(self, game): 18 | GObject.GObject.__init__(self) 19 | self.game = game 20 | 21 | def __str__(self): 22 | return str(self.game) 23 | 24 | def get_game_type(self): 25 | return type(self.game) 26 | 27 | 28 | class GamesListStore(Gio.ListStore): 29 | """A GioListStore that exclusively stores items of type GameGObject""" 30 | 31 | __gtype_name__ = "GamesListStore" 32 | 33 | def __init__(self) -> None: 34 | super().__init__(item_type=GameGObject) 35 | 36 | def extend(self, games: Iterable[GenericGame]): 37 | """Add multiple games to itself. 38 | Will only emit `Gio.ListModel::items-changed` once.""" 39 | store_len = self.get_n_items() 40 | items = list(map(lambda g: GameGObject(g), games)) 41 | self.splice(store_len, 0, items) 42 | 43 | def remove_of_type(self, _type: type): 44 | """Remove all the GameGObjects wrapping a game of the given type. 45 | Will emit multiple `Gio.ListModel::items-changed`""" 46 | index = 0 47 | while index < self.get_n_items(): 48 | game = self.get_item(index) 49 | if game.get_game_type() == _type: 50 | self.remove(index) 51 | continue 52 | index += 1 53 | 54 | 55 | class Library(): 56 | """A class representing a multi-source game library""" 57 | 58 | _source_games_map: dict[type[Source], list[GenericGame]] = dict() 59 | _hidden_sources: set[type[Source]] = set() 60 | 61 | # View containing the games to display in the UI 62 | gio_list_store: GamesListStore 63 | gtk_selection_model: Gtk.SelectionModel 64 | 65 | def __init__(self): 66 | self.gio_list_store = GamesListStore() 67 | self.gtk_selection_model = Gtk.SingleSelection(can_unselect=True) 68 | self.gtk_selection_model.set_model(self.gio_list_store) 69 | for klass in all_sources: 70 | self._source_games_map[klass] = list() 71 | 72 | def __iter__(self): 73 | """Iterate over the library's games. 74 | No order is guaranteed.""" 75 | for games in self._source_games_map.values(): 76 | for game in games: 77 | yield game 78 | 79 | def clear(self) -> None: 80 | """Empty the library""" 81 | for klass in self._source_games_map.keys(): 82 | self._source_games_map[klass].clear() 83 | self.gio_list_store.remove_all() 84 | 85 | def extend(self, klass: type[Source], games: Iterable[GenericGame]): 86 | """Add games from a given source to the library""" 87 | self._source_games_map[klass].extend(games) 88 | if klass in self._hidden_sources: return 89 | self.gio_list_store.extend(games) 90 | 91 | def hide_source(self, klass: type[Source]): 92 | """Hide a source in the view""" 93 | self._hidden_sources.add(klass) 94 | self.gio_list_store.remove_of_type(klass.game_class) 95 | 96 | def show_source(self, klass: type[Source]): 97 | """Show a source in the view""" 98 | if klass not in self._hidden_sources: return 99 | self._hidden_sources.remove(klass) 100 | games = self._source_games_map[klass] 101 | self.gio_list_store.extend(games) 102 | 103 | def scan(self) -> None: 104 | """Scan the library sources""" 105 | # TODO Non-blocking sources scans 106 | self.clear() 107 | for klass in all_sources: 108 | source = klass() 109 | is_scannable = source.is_scannable() 110 | if not is_scannable : 111 | print(f"🚫 Skipping source {source.name} : {str(is_scannable)}") 112 | continue 113 | print(f"🔍 Scanning source {source.name}") 114 | try: 115 | games = source.scan() 116 | except Exception as err: 117 | print(f"Error while scanning {source.name}") 118 | print_tb(sys.exc_info()[2]) 119 | print(err) 120 | else: 121 | self.extend(klass, games) 122 | print("Scan finished") 123 | 124 | def print(self) -> None: 125 | """Print the games in the library to stdout""" 126 | for game in self: 127 | print(f"* {str(game)}") -------------------------------------------------------------------------------- /gali/src/main.py: -------------------------------------------------------------------------------- 1 | #!/bin/python3 2 | 3 | import sys 4 | from os import environ 5 | 6 | # TODO apply flatpak specific setup 7 | # (Trying to universalize distribution to non-flatpak) 8 | 9 | def main(): 10 | 11 | from gali.ui.application import Application 12 | from gali.utils.sandbox import in_flatpak_sandbox 13 | 14 | # In flatpak, set the TMPDIR in $XDG_RUNTIME_DIR/app/$FLATPAK_ID 15 | # see https://docs.flatpak.org/en/latest/sandbox-permissions.html?highlight=XDG_RUNTIME_DIR#filesystem-access 16 | if in_flatpak_sandbox(): 17 | # TODO Are these env vars guaranteed to be set ? 18 | XDG_RUNTIME_DIR = environ.get("XDG_RUNTIME_DIR") 19 | FLATPAK_ID = environ.get("FLATPAK_ID") 20 | environ["TMPDIR"] = f"{XDG_RUNTIME_DIR}/app/{FLATPAK_ID}" 21 | 22 | # Create and run app 23 | application = Application() 24 | return application.run(sys.argv) 25 | 26 | if __name__ == "__main__": 27 | main() -------------------------------------------------------------------------------- /gali/src/meson.build: -------------------------------------------------------------------------------- 1 | gnome = import('gnome') 2 | 3 | gnome.compile_resources('gali', 4 | 'gali.gresource.xml', 5 | gresource_bundle: true, 6 | install: true, 7 | install_dir: pkgdatadir, 8 | ) 9 | 10 | python = import('python') 11 | 12 | conf = configuration_data() 13 | conf.set('PYTHON', python.find_installation('python3').path()) 14 | conf.set('VERSION', meson.project_version()) 15 | conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir'))) 16 | conf.set('pkgdatadir', pkgdatadir) 17 | 18 | configure_file( 19 | input: 'gali.in', 20 | output: 'gali', 21 | configuration: conf, 22 | install: true, 23 | install_dir: get_option('bindir') 24 | ) 25 | 26 | install_subdir('sources', install_dir: moduledir) 27 | install_subdir('ui', install_dir: moduledir) 28 | install_subdir('utils', install_dir: moduledir) 29 | install_data( 30 | [ 31 | '__init__.py', 32 | 'main.py', 33 | 'library.py', 34 | 'launcher.py', 35 | 'singletons.py', 36 | 'game_wrapper_process.py' 37 | ], 38 | install_dir: moduledir 39 | ) -------------------------------------------------------------------------------- /gali/src/singletons.py: -------------------------------------------------------------------------------- 1 | from gali.library import Library 2 | from gali.launcher import Launcher 3 | 4 | # Library containing games, that can trigger scans 5 | library = Library() 6 | 7 | # Launcher that starts a game, then handles stopping it 8 | launcher = Launcher() -------------------------------------------------------------------------------- /gali/src/sources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/all_sources.py: -------------------------------------------------------------------------------- 1 | from gali.sources.cemu.lutris.cemu_lutris_source import CemuLutrisSource 2 | from gali.sources.citra.native.citra_native_source import CitraNativeSource 3 | from gali.sources.citra.flatpak.citra_flatpak_source import CitraFlatpakSource 4 | from gali.sources.desktop.desktop_source import DesktopSource 5 | from gali.sources.dolphin.native.dolphin_native_source import DolphinNativeSource 6 | from gali.sources.dolphin.flatpak.dolphin_flatpak_source import DolphinFlatpakSource 7 | from gali.sources.heroic.native.heroic_native_source import HeroicNativeSource 8 | from gali.sources.heroic.flatpak.heroic_flatpak_source import HeroicFlatpakSource 9 | from gali.sources.itch.native.itch_native_source import ItchNativeSource 10 | from gali.sources.legendary.native.legendary_native_source import LegendaryNativeSource 11 | from gali.sources.lutris.native.lutris_native_source import LutrisNativeSource 12 | from gali.sources.ppsspp.native.ppsspp_native_source import PPSSPPNativeSource 13 | from gali.sources.ppsspp.flatpak.ppsspp_flatpak_source import PPSSPPFlatpakSource 14 | from gali.sources.retroarch.native.retroarch_native_source import RetroarchNativeSource 15 | from gali.sources.retroarch.flatpak.retroarch_flatpak_source import RetroarchFlatpakSource 16 | from gali.sources.steam.native.steam_native_source import SteamNativeSource 17 | from gali.sources.steam.flatpak.steam_flatpak_source import SteamFlatpakSource 18 | from gali.sources.yuzu.native.yuzu_native_source import YuzuNativeSource 19 | from gali.sources.yuzu.flatpak.yuzu_flatpak_source import YuzuFlatpakSource 20 | 21 | # Register here all the scannable sources. 22 | 23 | all_sources = [ 24 | CemuLutrisSource, 25 | CitraNativeSource, 26 | CitraFlatpakSource, 27 | DesktopSource, 28 | DolphinNativeSource, 29 | DolphinFlatpakSource, 30 | HeroicNativeSource, 31 | HeroicFlatpakSource, 32 | ItchNativeSource, 33 | LegendaryNativeSource, 34 | LutrisNativeSource, 35 | PPSSPPNativeSource, 36 | PPSSPPFlatpakSource, 37 | RetroarchNativeSource, 38 | RetroarchFlatpakSource, 39 | SteamNativeSource, 40 | SteamFlatpakSource, 41 | YuzuNativeSource, 42 | YuzuFlatpakSource 43 | ] 44 | 45 | -------------------------------------------------------------------------------- /gali/src/sources/cemu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/cemu/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/cemu/cemu_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.emulation_game import EmulationGame 4 | 5 | 6 | @dataclass 7 | class CemuGame(EmulationGame): 8 | """Abstract class representing a Cemu game""" 9 | 10 | platform: str = field(default="Nintendo - Wii U", init=False) -------------------------------------------------------------------------------- /gali/src/sources/cemu/cemu_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.cemu.cemu_game import CemuGame 2 | from gali.sources.emulation_source import EmulationSource 3 | 4 | 5 | class CemuSource(EmulationSource): 6 | 7 | game_class: type[CemuGame] 8 | rom_extensions: tuple[str] = (".wud",".wux",".wad",".iso",".rpx",".elf") -------------------------------------------------------------------------------- /gali/src/sources/cemu/lutris/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/cemu/lutris/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/cemu/lutris/abc_cemu_lutris_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.cemu.cemu_game import CemuGame 4 | 5 | 6 | @dataclass 7 | class ABCCemuLutrisGame(CemuGame): 8 | """Class representing a Cemu in Lutris game""" 9 | 10 | wine_prefix_path: str = field(default="") 11 | cemu_slug: str = field(default="cemu") 12 | -------------------------------------------------------------------------------- /gali/src/sources/cemu/lutris/cemu_lutris_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.cemu.lutris.abc_cemu_lutris_game import ABCCemuLutrisGame 2 | from gali.sources.cemu.lutris.cemu_lutris_startup_chain import CemuLutrisStartupChain 3 | from gali.sources.startable import Startable 4 | 5 | class CemuLutrisGame(ABCCemuLutrisGame, Startable): 6 | """Class representing a Cemu in Lutris game""" 7 | 8 | startup_chains = [ 9 | CemuLutrisStartupChain 10 | ] 11 | -------------------------------------------------------------------------------- /gali/src/sources/cemu/lutris/cemu_lutris_source.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import sqlite3 3 | from math import inf 4 | from locale import getlocale, LC_MESSAGES 5 | from pathlib import PurePath 6 | from os import access, R_OK, PathLike 7 | from os.path import isfile 8 | from xml.etree.ElementTree import ElementTree # nosec B405 9 | from defusedxml.ElementTree import parse as xml_parse 10 | 11 | from gali.sources.cemu.cemu_source import CemuSource 12 | from gali.sources.cemu.lutris.cemu_lutris_game import CemuLutrisGame 13 | from gali.sources.lutris.native.lutris_native_source import LutrisNativeSource 14 | from gali.sources.game_dir import GameDir 15 | from gali.sources.scannable import UnscannableReason 16 | from gali.utils.rpx_metadata import RPXMetadata 17 | from gali.utils.wine_path import wine_to_posix 18 | from gali.utils.locations import HOME 19 | 20 | 21 | # TODO extract common points to a base CemuSource 22 | class CemuLutrisSource(CemuSource): 23 | 24 | name: str = "Cemu (Lutris)" 25 | game_class: type[CemuLutrisGame] = CemuLutrisGame 26 | 27 | def get_cemu_lutris_config_path(self) -> str: 28 | with sqlite3.connect(LutrisNativeSource.db_path) as connection: 29 | sql = "SELECT configpath FROM 'games' WHERE slug = 'cemu'" 30 | cursor = connection.execute(sql) 31 | row = cursor.fetchone() 32 | config_path = f"{HOME}/.config/lutris/games/{row[0]}.yml" 33 | return config_path 34 | 35 | def get_cemu_lutris_config(self, config_path: str) -> dict: 36 | file = open(config_path, "r", encoding="utf-8-sig") 37 | config = yaml.safe_load(file) 38 | file.close() 39 | return config 40 | 41 | def get_cemu_config(self, cemu_exe_path) -> ElementTree: 42 | config_dir = PurePath(cemu_exe_path).parent 43 | config_path = f"{config_dir}/settings.xml" 44 | config = xml_parse(config_path) 45 | return config 46 | 47 | def get_cached_games( 48 | self, 49 | wine_prefix_path: str, 50 | config: ElementTree 51 | ) -> tuple[CemuLutrisGame]: 52 | 53 | games = [] 54 | 55 | # Read XML tree 56 | elements = config.findall("./GameCache/Entry") 57 | for element in elements: 58 | 59 | # Get game data 60 | name = element.findtext("name", default=None) 61 | game_path = element.findtext("path", default=None) 62 | if name is None or game_path is None: 63 | continue 64 | 65 | # Convert wine path to posix 66 | game_path = wine_to_posix(wine_prefix_path, game_path) 67 | 68 | # Build game 69 | game = self.game_class( 70 | name=name, 71 | game_path=game_path 72 | ) 73 | games.append(game) 74 | 75 | return tuple(games) 76 | 77 | def get_rom_dirs( 78 | self, 79 | wine_prefix_path: str, 80 | config: ElementTree 81 | ) -> tuple[str]: 82 | game_dirs = [] 83 | elements = config.findall("./GamePaths/Entry") 84 | for element in elements: 85 | path = wine_to_posix(wine_prefix_path, element.text) 86 | game_dir = GameDir(path, inf) 87 | game_dirs.append(game_dir) 88 | return tuple(game_dirs) 89 | 90 | def get_rom_games(self, rom_dirs: tuple[str]) -> tuple[CemuLutrisGame]: 91 | 92 | games = [] 93 | 94 | # Get locale language (for rpx games) 95 | locale = getlocale(LC_MESSAGES) 96 | locale_lang_code = locale[0] 97 | if locale_lang_code is not None: 98 | locale_lang_code = locale_lang_code[0:2] 99 | locale_lang_code = locale_lang_code.lower() 100 | 101 | # Scan every rom dir 102 | for rom_dir in rom_dirs: 103 | 104 | # Scan for roms 105 | try: 106 | rom_paths = self.get_rom_paths(rom_dir, self.rom_extensions) 107 | except OSError: 108 | continue 109 | 110 | # Build games from roms 111 | for rom_path in rom_paths: 112 | pure_path = PurePath(rom_path) 113 | extension = pure_path.suffix 114 | basename = pure_path.stem 115 | 116 | # Special case for metadata-rich ".rpx" games 117 | if extension == ".rpx": 118 | metadata: RPXMetadata = RPXMetadata.from_rom_path(rom_path) 119 | name = metadata.long_name.get(locale_lang_code, basename) 120 | game = self.game_class(name=name, game_path=rom_path) 121 | games.append(game) 122 | 123 | # Other games 124 | else: 125 | game = self.game_class(name=basename, game_path=rom_path) 126 | games.append(game) 127 | 128 | return tuple(games) 129 | 130 | def scan(self) -> tuple[CemuLutrisGame]: 131 | 132 | # Read lutris config for cemu 133 | cemu_lutris_config_path = self.get_cemu_lutris_config_path() 134 | cemu_lutris_config = self.get_cemu_lutris_config(cemu_lutris_config_path) 135 | wine_prefix_path = cemu_lutris_config["game"]["prefix"] 136 | exe_path = cemu_lutris_config["game"]["exe"] 137 | exe_path = f"{wine_prefix_path}/{exe_path}" 138 | 139 | # Read cemu's config 140 | cemu_config = self.get_cemu_config(exe_path) 141 | 142 | # Scan for games 143 | if self.prefer_cache: 144 | games = self.get_cached_games(wine_prefix_path, cemu_config) 145 | else: 146 | game_dirs = self.get_rom_dirs(wine_prefix_path, cemu_config) 147 | games = self.get_rom_games(game_dirs) 148 | 149 | return tuple(games) 150 | 151 | def is_scannable(self): 152 | # TODO Evaluate if source dependencies are a good idea 153 | # Maybe depending on a Lutris game is a better idea... 154 | # Instead of re-scanning, use a criteria for a game to be the source's 155 | # starting point. 156 | # Any LutrisGame with the slug "cemu" is fine, just specify so. 157 | 158 | # Not scannable if no Lutris DB is present 159 | file = LutrisNativeSource.db_path 160 | if (not isfile(file)) or (not access(file, R_OK)): 161 | return UnscannableReason(f"Lutris db file is not readable : {file}") 162 | 163 | # Not scannable if Lutris doesn't have Cemu installed 164 | connection = sqlite3.connect(file) 165 | sql = "SELECT count(*) FROM 'games' WHERE slug = 'cemu'" 166 | cursor = connection.execute(sql) 167 | row = cursor.fetchone() 168 | connection.close() 169 | has_cemu = row[0] > 0 170 | if not has_cemu: 171 | return UnscannableReason("Cemu is not installed in Lutris") 172 | 173 | return True -------------------------------------------------------------------------------- /gali/src/sources/cemu/lutris/cemu_lutris_startup_chain.py: -------------------------------------------------------------------------------- 1 | from shlex import quote 2 | 3 | from gali.sources.cemu.lutris.abc_cemu_lutris_game import ABCCemuLutrisGame 4 | from gali.sources.script_startup_chain import ScriptStartupChain 5 | from gali.utils.lutris_export_script import lutris_export_script 6 | from gali.utils.wine_path import posix_to_wine 7 | 8 | class CemuLutrisStartupChain(ScriptStartupChain): 9 | 10 | name: str = "Cemu in Lutris" 11 | game: ABCCemuLutrisGame 12 | 13 | def make_script(self) -> None: 14 | 15 | # Export base Lutris script that starts Cemu 16 | lutris_export_script("cemu", self.tempfile) 17 | 18 | # Get game args passed to Cemu 19 | game_path_wine = posix_to_wine(self.game.game_path) 20 | args = ["--game", quote(game_path_wine)] 21 | 22 | # ? On large scripts this may hang, however I think Lutris scripts are not that big. 23 | # Get script contents, with trailing whitespace removed 24 | file_contents = "" 25 | with open(self.tempfile, "r", encoding="utf-8-sig") as file: 26 | file_contents = file.read().rstrip() 27 | 28 | # Add cemu arguments to script 29 | to_append = " " + " ".join(args) 30 | file_contents += to_append 31 | with open(self.tempfile, "w", encoding="utf-8-sig") as file: 32 | file.write(file_contents) -------------------------------------------------------------------------------- /gali/src/sources/citra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/citra/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/citra/citra_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.emulation_game import EmulationGame 4 | 5 | 6 | @dataclass 7 | class CitraGame(EmulationGame): 8 | 9 | platform: str = field(default="Nintendo - 3DS", init=False) -------------------------------------------------------------------------------- /gali/src/sources/citra/citra_source.py: -------------------------------------------------------------------------------- 1 | from math import inf 2 | from pathlib import PurePath 3 | 4 | from gali.utils.explicit_config_parser import ExplicitConfigParser 5 | from gali.sources.citra.citra_game import CitraGame 6 | from gali.sources.emulation_source import EmulationSource 7 | from gali.sources.game_dir import GameDir 8 | from gali.sources.file_dependent_source import FileDependentSource 9 | 10 | 11 | class CitraSource(EmulationSource, FileDependentSource): 12 | 13 | name: str 14 | config_path: str 15 | game_class: type[CitraGame] 16 | 17 | rom_extensions: tuple[str] = (".3ds", ".cci") 18 | 19 | def get_config(self) -> ExplicitConfigParser: 20 | config = ExplicitConfigParser() 21 | config.read_one(self.config_path, encoding="utf-8-sig") 22 | return config 23 | 24 | def get_rom_dirs(self, config: ExplicitConfigParser) -> tuple[GameDir]: 25 | rom_dirs = [] 26 | n_dirs = config.getint("UI", r"Paths\gamedirs\size", fallback=0) 27 | for i in range(1, n_dirs + 1): 28 | deep = config.getboolean( 29 | "UI", 30 | f"Paths\\gamedirs\\{i}\\deep_scan", 31 | fallback=False 32 | ) 33 | path = config.get( 34 | "UI", 35 | f"Paths\\gamedirs\\{i}\\path", 36 | fallback=None 37 | ) 38 | if path is None: 39 | continue 40 | if path in ("INSTALLED", "SYSTEM"): 41 | continue 42 | depth = inf if deep else 0 43 | rom_dirs.append(GameDir(path, depth)) 44 | return tuple(rom_dirs) 45 | 46 | def get_rom_games(self, rom_dirs: tuple[GameDir]) -> tuple[CitraGame]: 47 | games = [] 48 | for rom_dir in rom_dirs: 49 | rom_paths = [] 50 | try: 51 | rom_paths = self.get_rom_paths(rom_dir, self.rom_extensions) 52 | except OSError: 53 | continue 54 | for path in rom_paths: 55 | name = PurePath(path).name 56 | game = self.game_class( 57 | name=name, 58 | game_path=path, 59 | is_installed=True, 60 | ) 61 | games.append(game) 62 | return tuple(games) 63 | 64 | def scan(self) -> tuple[CitraGame]: 65 | config = self.get_config() 66 | rom_dirs = self.get_rom_dirs(config) 67 | rom_games = self.get_rom_games(rom_dirs) 68 | return rom_games 69 | 70 | def get_precondition_file_path(self): 71 | return self.config_path -------------------------------------------------------------------------------- /gali/src/sources/citra/citra_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.stemmed_cli_startup_chain import StemmedCLIStartupChain 4 | from gali.sources.citra.citra_game import CitraGame 5 | 6 | 7 | class CitraStartupChain(StemmedCLIStartupChain): 8 | 9 | game: CitraGame 10 | 11 | def get_start_command_suffix(self) -> Iterable[str]: 12 | return [self.game.game_path] -------------------------------------------------------------------------------- /gali/src/sources/citra/flatpak/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/citra/flatpak/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/citra/flatpak/citra_flatpak_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.citra.citra_game import CitraGame 3 | from gali.sources.citra.flatpak.citra_flatpak_startup_chain import CitraFlatpakStartupChain 4 | 5 | 6 | class CitraFlatpakGame(CitraGame, Startable): 7 | 8 | startup_chains = [ 9 | CitraFlatpakStartupChain 10 | ] -------------------------------------------------------------------------------- /gali/src/sources/citra/flatpak/citra_flatpak_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.citra.flatpak.citra_flatpak_game import CitraFlatpakGame 2 | from gali.sources.citra.citra_source import CitraSource 3 | from gali.utils.locations import HOME 4 | 5 | class CitraFlatpakSource(CitraSource): 6 | 7 | name: str = "Citra (Flatpak)" 8 | game_class: type[CitraFlatpakGame] = CitraFlatpakGame 9 | config_path: str = f"{HOME}/.var/app/org.citra_emu.citra/config/citra-emu/qt-config.ini" 10 | -------------------------------------------------------------------------------- /gali/src/sources/citra/flatpak/citra_flatpak_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.citra.citra_startup_chain import CitraStartupChain 2 | 3 | 4 | class CitraFlatpakStartupChain(CitraStartupChain): 5 | 6 | name = "Citra Flatpak" 7 | stem = ["flatpak", "run", "org.citra_emu.citra"] -------------------------------------------------------------------------------- /gali/src/sources/citra/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/citra/native/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/citra/native/citra_native_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.citra.citra_game import CitraGame 3 | from gali.sources.citra.native.citra_native_startup_chain import CitraNativeStartupChain 4 | 5 | 6 | class CitraNativeGame(CitraGame, Startable): 7 | 8 | startup_chains = [ 9 | CitraNativeStartupChain 10 | ] -------------------------------------------------------------------------------- /gali/src/sources/citra/native/citra_native_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.citra.citra_source import CitraSource 2 | from gali.sources.citra.native.citra_native_game import CitraNativeGame 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class CitraNativeSource(CitraSource): 7 | 8 | name: str = "Citra" 9 | game_class: type[CitraNativeGame] = CitraNativeGame 10 | config_path: str = f"{HOME}/.config/citra-emu/qt-config.ini" -------------------------------------------------------------------------------- /gali/src/sources/citra/native/citra_native_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.citra.citra_startup_chain import CitraStartupChain 2 | 3 | 4 | class CitraNativeStartupChain(CitraStartupChain): 5 | 6 | name = "Citra" 7 | stem = ["citra-qt"] -------------------------------------------------------------------------------- /gali/src/sources/cli_startup_chain.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Iterable 3 | from subprocess import run 4 | 5 | from gali.sources.startup_chain import StartupChain 6 | from gali.utils.sandbox import in_flatpak_sandbox 7 | 8 | 9 | class CLIStartupChain(StartupChain): 10 | """Class representing a startup chain that starts a game from a command in a subprocess""" 11 | 12 | @abstractmethod 13 | def get_start_command(self) -> Iterable[str]: 14 | """Get the start command""" 15 | pass 16 | 17 | def start(self) -> None: 18 | """Start the game from its command in a subprocess""" 19 | 20 | # Get shell command 21 | args = list() 22 | if in_flatpak_sandbox(): args.extend(["flatpak-spawn", "--host"]) 23 | args.extend(self.get_start_command()) 24 | 25 | # Start command in a subprocess 26 | print(f"Starting \"{self.game.name}\"") 27 | print(args) 28 | run(args=args) -------------------------------------------------------------------------------- /gali/src/sources/desktop/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/desktop/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/desktop/abc_desktop_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.generic_game import GenericGame 4 | 5 | 6 | @dataclass 7 | class ABCDesktopGame(GenericGame): 8 | 9 | platform: str = field(default="PC", init=False) 10 | is_installed: bool = field(default=True, init=False) 11 | exec_str: str = field(default=None) -------------------------------------------------------------------------------- /gali/src/sources/desktop/desktop_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.desktop.abc_desktop_game import ABCDesktopGame 3 | from gali.sources.desktop.desktop_startup_chain import DesktopStartupChain 4 | 5 | 6 | class DesktopGame(ABCDesktopGame, Startable): 7 | 8 | startup_chains = [ 9 | DesktopStartupChain 10 | ] -------------------------------------------------------------------------------- /gali/src/sources/desktop/desktop_source.py: -------------------------------------------------------------------------------- 1 | from math import inf 2 | 3 | from gali.sources.game_dir import GameDir 4 | from gali.sources.source import Source 5 | from gali.sources.scannable import UnscannableReason 6 | from gali.sources.desktop.desktop_game import DesktopGame 7 | from gali.utils.locations import XDG_DATA_DIRS, XDG_DATA_HOME 8 | from gali.utils.deep_find_files import deep_find_files 9 | from gali.utils.explicit_config_parser import ExplicitConfigParser 10 | from gali.utils.sandbox import in_flatpak_sandbox 11 | 12 | 13 | class DesktopSource(Source): 14 | 15 | name: str = "Desktop Entries" 16 | game_class: type[DesktopGame] = DesktopGame 17 | extensions: tuple[str] = (".desktop",) 18 | 19 | def get_desktop_dirs(self) -> tuple[GameDir]: 20 | 21 | # Regular dirs 22 | dirs = [ 23 | f"{XDG_DATA_HOME}/applications" 24 | "/usr/share/applications" 25 | "/usr/local/share/applications" 26 | ] 27 | 28 | # User defined dirs 29 | for xdg_data_dir in XDG_DATA_DIRS: 30 | dirs.append(f"{xdg_data_dir}/applications") 31 | 32 | return tuple(dirs) 33 | 34 | def get_desktop_paths(self, desktop_dirs) -> tuple[str]: 35 | desktop_paths = [] 36 | for root_dir in desktop_dirs: 37 | try: 38 | found = deep_find_files(root_dir, inf, self.extensions) 39 | except OSError: 40 | continue 41 | desktop_paths.extend(found) 42 | return tuple(desktop_paths) 43 | 44 | def get_desktop_games(self, desktop_paths) -> tuple[DesktopGame]: 45 | 46 | games = [] 47 | 48 | for path in desktop_paths: 49 | 50 | # Read desktop entry data 51 | data = ExplicitConfigParser() 52 | try: 53 | data.read_one(path) 54 | except IOError: 55 | continue 56 | 57 | # Filter out hidden entries 58 | hidden = data.getboolean( 59 | "Desktop Entry", 60 | "Hidden", 61 | fallback=False 62 | ) 63 | if hidden: 64 | continue 65 | no_display = data.getboolean( 66 | "Desktop Entry", 67 | "NoDisplay", 68 | fallback=False 69 | ) 70 | if no_display: 71 | continue 72 | 73 | # Filter out entries that are not games 74 | categories = data.get("Desktop Entry", "Categories", fallback="") 75 | categories = categories.split(";") 76 | if not ("Game" in categories): 77 | continue 78 | 79 | # Build game 80 | name = data.get("Desktop Entry", "Name") 81 | exec_str = data.get("Desktop Entry", "Exec", raw=True) 82 | game = self.game_class( 83 | name=name, 84 | exec_str=exec_str 85 | ) 86 | games.append(game) 87 | 88 | return tuple(games) 89 | 90 | def scan(self) -> tuple[DesktopGame]: 91 | dirs = self.get_desktop_dirs() 92 | paths = self.get_desktop_paths(dirs) 93 | games = self.get_desktop_games(paths) 94 | return games 95 | 96 | def is_scannable(self): 97 | """ 98 | Desktop entries cannot yet be read correctly from inside flatpak's sandbox. 99 | For a future fix, see : 100 | https://github.com/flatpak/xdg-desktop-portal/issues/809 101 | """ 102 | if in_flatpak_sandbox(): 103 | return UnscannableReason("Not scannable inside of the flatpak sandbox") 104 | return True -------------------------------------------------------------------------------- /gali/src/sources/desktop/desktop_startup_chain.py: -------------------------------------------------------------------------------- 1 | import re 2 | import shlex 3 | from typing import Iterable 4 | 5 | from gali.sources.cli_startup_chain import CLIStartupChain 6 | from gali.sources.desktop.abc_desktop_game import ABCDesktopGame 7 | 8 | 9 | class DesktopStartupChain(CLIStartupChain): 10 | 11 | game: ABCDesktopGame 12 | name = "Desktop Entry" 13 | 14 | def get_start_command(self) -> Iterable[str]: 15 | def filter_fn(string: str): 16 | unwanted = re.compile("%[fFuUdDnNickvm]") 17 | return unwanted.fullmatch(string) is None 18 | split_exec = shlex.split(self.game.exec_str) 19 | args = tuple(filter(filter_fn, split_exec)) 20 | return args -------------------------------------------------------------------------------- /gali/src/sources/dolphin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/dolphin/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/dolphin/dolphin_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.emulation_game import EmulationGame 4 | 5 | 6 | @dataclass 7 | class DolphinGame(EmulationGame): 8 | 9 | platform: str = field(default="Nintendo - Gamecube / Wii", init=False) -------------------------------------------------------------------------------- /gali/src/sources/dolphin/dolphin_source.py: -------------------------------------------------------------------------------- 1 | from math import inf 2 | from pathlib import PurePath 3 | 4 | from gali.sources.dolphin.dolphin_game import DolphinGame 5 | from gali.utils.explicit_config_parser import ExplicitConfigParser 6 | from gali.sources.game_dir import GameDir 7 | from gali.sources.emulation_source import EmulationSource 8 | from gali.sources.file_dependent_source import FileDependentSource 9 | 10 | 11 | class DolphinSource(EmulationSource, FileDependentSource): 12 | 13 | name: str 14 | game_class: type[DolphinGame] 15 | config_path: str 16 | 17 | rom_extensions: tuple[str] = (".ciso", ".iso", ".wbfs", ".gcm", ".gcz") 18 | 19 | def get_config(self) -> ExplicitConfigParser: 20 | config = ExplicitConfigParser() 21 | config.read(self.config_path, encoding="utf-8-sig") 22 | return config 23 | 24 | def get_rom_dirs(self, config: ExplicitConfigParser) -> tuple[GameDir]: 25 | rom_dirs = [] 26 | n_dirs = config.getint( 27 | "General", 28 | "ISOPaths", 29 | fallback=0 30 | ) 31 | deep = config.getboolean( 32 | "General", 33 | "RecursiveISOPaths", 34 | fallback=False 35 | ) 36 | depth = inf if deep else 0 37 | for i in range(n_dirs): 38 | path = config.get("General", f"ISOPath{i}", fallback=None) 39 | if path is None: 40 | continue 41 | rom_dirs.append(GameDir(path, depth)) 42 | return tuple(rom_dirs) 43 | 44 | def get_rom_games(self, rom_dirs: tuple[GameDir]) -> tuple[DolphinGame]: 45 | games = [] 46 | for rom_dir in rom_dirs: 47 | rom_paths = [] 48 | try: 49 | rom_paths = self.get_rom_paths(rom_dir, self.rom_extensions) 50 | except OSError: 51 | continue 52 | for path in rom_paths: 53 | name = PurePath(path).name 54 | game = self.game_class( 55 | name=name, 56 | game_path=path, 57 | is_installed=True, 58 | ) 59 | games.append(game) 60 | return tuple(games) 61 | 62 | def scan(self) -> tuple[DolphinGame]: 63 | config = self.get_config() 64 | rom_dirs = self.get_rom_dirs(config) 65 | rom_games = self.get_rom_games(rom_dirs) 66 | return rom_games 67 | 68 | def get_precondition_file_path(self): 69 | return self.config_path 70 | -------------------------------------------------------------------------------- /gali/src/sources/dolphin/dolphin_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.stemmed_cli_startup_chain import StemmedCLIStartupChain 4 | from gali.sources.dolphin.dolphin_game import DolphinGame 5 | 6 | 7 | class DolphinStartupChain(StemmedCLIStartupChain): 8 | 9 | game: DolphinGame 10 | 11 | def get_start_command_suffix(self) -> Iterable[str]: 12 | suffix = list() 13 | # TODO add option to hide the UI 14 | # if kwargs["no_ui"]: suffix.append("-b") 15 | suffix.extend(["-e", self.game.game_path]) 16 | return suffix -------------------------------------------------------------------------------- /gali/src/sources/dolphin/flatpak/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/dolphin/flatpak/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/dolphin/flatpak/dolphin_flatpak_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.dolphin.dolphin_game import DolphinGame 3 | from gali.sources.dolphin.flatpak.dolphin_flatpak_startup_chain import DolphinFlatpakStartupChain 4 | 5 | 6 | class DolphinFlatpakGame(DolphinGame, Startable): 7 | 8 | startup_chains = [ 9 | DolphinFlatpakStartupChain 10 | ] -------------------------------------------------------------------------------- /gali/src/sources/dolphin/flatpak/dolphin_flatpak_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.dolphin.dolphin_source import DolphinSource 2 | from gali.sources.dolphin.flatpak.dolphin_flatpak_game import DolphinFlatpakGame 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class DolphinFlatpakSource(DolphinSource): 7 | 8 | name: str = "Dolphin (Flatpak)" 9 | game_class: type[DolphinFlatpakGame] = DolphinFlatpakGame 10 | config_path: str = f"{HOME}/.var/app/org.DolphinEmu.dolphin-emu/config/dolphin-emu/Dolphin.ini" -------------------------------------------------------------------------------- /gali/src/sources/dolphin/flatpak/dolphin_flatpak_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.dolphin.dolphin_startup_chain import DolphinStartupChain 2 | 3 | 4 | class DolphinFlatpakStartupChain(DolphinStartupChain): 5 | 6 | name = "Dolphin Flatpak" 7 | stem = ["flatpak", "run", "org.DolphinEmu.dolphin-emu"] -------------------------------------------------------------------------------- /gali/src/sources/dolphin/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/dolphin/native/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/dolphin/native/dolphin_native_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.dolphin.dolphin_game import DolphinGame 3 | from gali.sources.dolphin.native.dolphin_native_startup_chain import DolphinNativeStartupChain 4 | 5 | 6 | class DolphinNativeGame(DolphinGame, Startable): 7 | 8 | startup_chains = [ 9 | DolphinNativeStartupChain 10 | ] 11 | -------------------------------------------------------------------------------- /gali/src/sources/dolphin/native/dolphin_native_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.dolphin.dolphin_source import DolphinSource 2 | from gali.sources.dolphin.native.dolphin_native_game import DolphinNativeGame 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class DolphinNativeSource(DolphinSource): 7 | 8 | name: str = "Dolphin" 9 | game_class: type[DolphinNativeGame] = DolphinNativeGame 10 | config_path: str = f"{HOME}/.config/dolphin-emu/Dolphin.ini" -------------------------------------------------------------------------------- /gali/src/sources/dolphin/native/dolphin_native_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.dolphin.dolphin_startup_chain import DolphinStartupChain 2 | 3 | 4 | class DolphinNativeStartupChain(DolphinStartupChain): 5 | 6 | name = "Dolphin" 7 | stem = ["dolphin-emu"] -------------------------------------------------------------------------------- /gali/src/sources/emulation_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.generic_game import GenericGame 4 | 5 | 6 | @dataclass 7 | class EmulationGame(GenericGame): 8 | """A class representing emulation games""" 9 | 10 | game_path: str = field(default="") 11 | -------------------------------------------------------------------------------- /gali/src/sources/emulation_source.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Sequence 2 | 3 | from gali.sources.source import Source 4 | from gali.utils.deep_find_files import deep_find_files 5 | 6 | 7 | class EmulationSource(Source): 8 | 9 | rom_extensions: Sequence[str] = [] 10 | 11 | def get_rom_paths(self, rom_dir, rom_extensions) -> Iterable[str]: 12 | """Get path to game roms""" 13 | return deep_find_files( 14 | rom_dir.path, 15 | rom_dir.depth, 16 | rom_extensions 17 | ) 18 | -------------------------------------------------------------------------------- /gali/src/sources/file_dependent_source.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from os import access, R_OK, PathLike 3 | from os.path import isfile 4 | 5 | from gali.sources.source import Source 6 | from gali.sources.scannable import UnscannableReason 7 | 8 | class FileDependentSource(Source): 9 | """Abstract class representing a scannable that depends on a file to be 10 | present and readable for is_scannable to return true""" 11 | 12 | @abstractmethod 13 | def get_precondition_file_path(self) -> PathLike: 14 | pass 15 | 16 | def is_scannable(self): 17 | path = self.get_precondition_file_path() 18 | if (not isfile(path)) or (not access(path, R_OK)): 19 | return UnscannableReason(f"Precondition file is not readable : {path}") 20 | return True -------------------------------------------------------------------------------- /gali/src/sources/game.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | 4 | class Game(ABC): 5 | """Abstract base class representing a game. 6 | * For type-checking purposes only, inherit directly from BaseGame.""" 7 | 8 | name: str 9 | platform: str 10 | is_installed: bool -------------------------------------------------------------------------------- /gali/src/sources/game_dir.py: -------------------------------------------------------------------------------- 1 | class GameDir(): 2 | 3 | path: str 4 | depth: int = 0 5 | 6 | def __init__(self, path, depth) -> None: 7 | self.path = path 8 | self.depth = depth 9 | -------------------------------------------------------------------------------- /gali/src/sources/generic_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.game import Game 4 | 5 | 6 | @dataclass 7 | class GenericGame(Game): 8 | """Base class representing a game""" 9 | 10 | name: str = field(default="") 11 | platform: str = field(default="") 12 | is_installed: bool = field(default=True) 13 | 14 | # TODO extract to an interface 15 | # image_box_art: str = field(default=None, compare=False) 16 | # image_banner: str = field(default=None, compare=False) 17 | # image_icon: str = field(default=None, compare=False) 18 | 19 | def __str__(self) -> str: 20 | name = self.name.replace("\n", " ") 21 | return name 22 | -------------------------------------------------------------------------------- /gali/src/sources/heroic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/heroic/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/heroic/flatpak/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/heroic/flatpak/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/heroic/flatpak/heroic_flatpak_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.heroic.heroic_source import HeroicSource 2 | from gali.sources.heroic.heroic_xdg_game import HeroicXDGGame 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class HeroicFlatpakSource(HeroicSource): 7 | 8 | name: str = "Heroic (Flatpak)" 9 | game_class: type[HeroicXDGGame] = HeroicXDGGame 10 | config_path: str = f"{HOME}/.var/app/com.heroicgameslauncher.hgl/config/heroic/lib-cache/library.json" -------------------------------------------------------------------------------- /gali/src/sources/heroic/heroic_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.generic_game import GenericGame 4 | 5 | 6 | @dataclass 7 | class HeroicGame(GenericGame): 8 | 9 | platform: str = field(default="PC", init=False) 10 | app_name: str = field(default=None) 11 | -------------------------------------------------------------------------------- /gali/src/sources/heroic/heroic_source.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from gali.sources.heroic.heroic_xdg_game import HeroicXDGGame 4 | from gali.sources.file_dependent_source import FileDependentSource 5 | 6 | 7 | class HeroicSource(FileDependentSource): 8 | 9 | name: str 10 | config_path: str 11 | 12 | def get_config(self) -> dict: 13 | try: 14 | with open(self.config_path, "r", encoding="utf-8-sig") as file: 15 | config = json.load(file) 16 | except Exception as err: 17 | raise err 18 | else: 19 | return config 20 | 21 | def get_games(self, config: dict) -> tuple[HeroicXDGGame]: 22 | 23 | games = [] 24 | library = config["library"] 25 | 26 | for entry in library: 27 | 28 | # Ignore non-games 29 | is_game = entry.get("is_game", False) 30 | if not is_game: 31 | continue 32 | 33 | # Skip broken entries 34 | name = entry.get("title", None) 35 | app_name = entry.get("app_name", None) 36 | if name is None or app_name is None: 37 | continue 38 | 39 | # Build games 40 | is_installed = entry.get("is_installed", True) 41 | game = self.game_class( 42 | name=name, 43 | app_name=app_name, 44 | is_installed=is_installed 45 | ) 46 | games.append(game) 47 | 48 | return tuple(games) 49 | 50 | def scan(self) -> tuple[HeroicXDGGame]: 51 | config = self.get_config() 52 | games = self.get_games(config) 53 | return games 54 | 55 | def get_precondition_file_path(self): 56 | return self.config_path 57 | -------------------------------------------------------------------------------- /gali/src/sources/heroic/heroic_xdg_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.heroic.heroic_game import HeroicGame 3 | from gali.sources.heroic.heroic_xdg_startup_chain import HeroicXDGStartupChain 4 | 5 | 6 | class HeroicXDGGame(HeroicGame, Startable): 7 | 8 | startup_chains = [ 9 | HeroicXDGStartupChain 10 | ] 11 | -------------------------------------------------------------------------------- /gali/src/sources/heroic/heroic_xdg_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.stemmed_cli_startup_chain import StemmedCLIStartupChain 4 | from gali.sources.heroic.heroic_game import HeroicGame 5 | 6 | 7 | class HeroicXDGStartupChain(StemmedCLIStartupChain): 8 | 9 | game: HeroicGame 10 | name = "Heroic" 11 | stem = ["xdg-open"] 12 | 13 | def get_start_command_suffix(self) -> Iterable[str]: 14 | return [f"heroic://launch/{self.game.app_name}"] -------------------------------------------------------------------------------- /gali/src/sources/heroic/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/heroic/native/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/heroic/native/heroic_native_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.heroic.heroic_source import HeroicSource 2 | from gali.sources.heroic.heroic_xdg_game import HeroicXDGGame 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class HeroicNativeSource(HeroicSource): 7 | 8 | name: str = "Heroic" 9 | game_class: type[HeroicXDGGame] = HeroicXDGGame 10 | config_path: str = f"{HOME}/.config/heroic/lib-cache/library.json" -------------------------------------------------------------------------------- /gali/src/sources/itch/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/itch/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/itch/itch_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.generic_game import GenericGame 4 | 5 | 6 | @dataclass 7 | class ItchGame(GenericGame): 8 | 9 | platform: str = field(default="PC", init=False) 10 | caves: list = field(default=None) 11 | verdict: dict = field(default=None) 12 | game_id: str = field(default=None) 13 | 14 | # Example verdict structure 15 | """{ 16 | "basePath": "/home/geoffrey/Jeux/itch/procrabstination", 17 | "totalSize": 193566920, 18 | "candidates": [ 19 | { 20 | "path": "Procrabstination.x86_64", 21 | "depth": 1, 22 | "flavor": "linux", 23 | "arch": "amd64", 24 | "size": 14720 25 | } 26 | ] 27 | }""" -------------------------------------------------------------------------------- /gali/src/sources/itch/itch_source.py: -------------------------------------------------------------------------------- 1 | import json 2 | from sqlite3 import connect, Row 3 | from gali.sources.itch.itch_game import ItchGame 4 | from gali.sources.file_dependent_source import FileDependentSource 5 | 6 | 7 | class ItchSource(FileDependentSource): 8 | 9 | name: str 10 | db_path: str 11 | game_class: type[ItchGame] 12 | 13 | db_request: str = """ 14 | SELECT 15 | caves.game_id, 16 | caves.verdict, 17 | games.title, 18 | games.cover_url, 19 | games.still_cover_url 20 | FROM 21 | 'caves' 22 | INNER JOIN 23 | 'games' 24 | ON 25 | caves.game_id = games.id 26 | ; 27 | """ 28 | 29 | """ 30 | From http://docs.itch.ovh/butlerd/master/#/?id=cave-struct 31 | 32 | A Cave corresponds to an "installed item" for a game. 33 | It maps one-to-one with an upload. There might be 0, 1, or several 34 | caves for a given game. Multiple caves for a single game is a rare-ish 35 | case (single-page bundles, bonus content) but one that should be handled. 36 | """ 37 | 38 | def get_db_contents(self) -> list[Row]: 39 | connection = connect(self.db_path) 40 | cursor = connection.execute(self.db_request) 41 | rows = cursor.fetchall() 42 | connection.close() 43 | return rows 44 | 45 | def get_games(self, rows: list[Row]) -> tuple[ItchGame]: 46 | games = [] 47 | for row in rows: 48 | 49 | # Raw fields 50 | game_id = row[0] 51 | raw_verdict = row[1] 52 | name = row[2] 53 | cover_url = row[3] 54 | still_cover_url = row[4] 55 | 56 | # Parse verdict 57 | try: 58 | verdict = json.loads(raw_verdict) 59 | except Exception: 60 | continue 61 | if len(verdict["candidates"]) == 0: 62 | continue 63 | 64 | # Game image (prefer still) 65 | # TODO enable when "ImagedGame"-ish interface is ready 66 | # image_box_art = cover_url 67 | # if still_cover_url is not None: 68 | # image_box_art = still_cover_url 69 | # image_icon = image_box_art 70 | 71 | # Build game 72 | game = self.game_class( 73 | game_id=game_id, 74 | name=name, 75 | verdict=verdict, 76 | is_installed=True 77 | # image_box_art=image_box_art, 78 | # image_icon=image_icon 79 | ) 80 | games.append(game) 81 | 82 | return tuple(games) 83 | 84 | def scan(self) -> tuple[ItchGame]: 85 | db_contents = self.get_db_contents() 86 | games = self.get_games(db_contents) 87 | return games 88 | 89 | def get_precondition_file_path(self): 90 | return self.db_path -------------------------------------------------------------------------------- /gali/src/sources/itch/itch_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startup_chain import StartupChain 2 | from gali.sources.itch.itch_game import ItchGame 3 | 4 | 5 | class ItchStartupChain(StartupChain): 6 | """An abstract Itch startup chain 7 | 8 | From http://docs.itch.ovh/butlerd/master/#/?id=verdict-struct 9 | 10 | A Verdict contains a wealth of information on how to “launch” or “open” 11 | a specific folder. 12 | 13 | From http://docs.itch.ovh/butlerd/master/#/?id=candidate-struct 14 | 15 | A Candidate is a potentially interesting launch target, be it a native 16 | executable, a Java or Love2D bundle, an HTML index, etc. 17 | """ 18 | 19 | game: ItchGame 20 | 21 | # TODO define the common parts for itch startup chains and group them here 22 | 23 | # --- Old code kept for later (maybe) --- 24 | # Build start command 25 | # base_path = self.verdict["basePath"] 26 | # exec_path = candidate["path"] 27 | # path = f"{base_path}/{exec_path}" 28 | # flavor = candidate["flavor"] -------------------------------------------------------------------------------- /gali/src/sources/itch/native/itch_java_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.itch.itch_startup_chain import ItchStartupChain 4 | from gali.sources.stemmed_cli_startup_chain import StemmedCLIStartupChain 5 | 6 | 7 | class ItchJavaStartupChain(ItchStartupChain, StemmedCLIStartupChain): 8 | 9 | name = "Itch Java candidate" 10 | stem = ["java", "-jar"] 11 | 12 | def get_start_command_suffix(self) -> Iterable[str]: 13 | # TODO start itch java candidate 14 | pass -------------------------------------------------------------------------------- /gali/src/sources/itch/native/itch_linux_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.itch.itch_startup_chain import ItchStartupChain 4 | from gali.sources.cli_startup_chain import CLIStartupChain 5 | 6 | 7 | class ItchLinuxStartupChain(ItchStartupChain, CLIStartupChain): 8 | 9 | name = "Itch Linux candidate" 10 | 11 | def get_start_command(self) -> Iterable[str]: 12 | # TODO start itch command candidate 13 | pass -------------------------------------------------------------------------------- /gali/src/sources/itch/native/itch_native_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.itch.itch_game import ItchGame 3 | from gali.sources.itch.native.itch_linux_startup_chain import ItchLinuxStartupChain 4 | from gali.sources.itch.native.itch_script_startup_chain import ItchScriptStartupChain 5 | from gali.sources.itch.native.itch_java_startup_chain import ItchJavaStartupChain 6 | 7 | 8 | class ItchNativeGame(ItchGame, Startable): 9 | 10 | startup_chains = list() 11 | 12 | def __post_init__(self) -> None: 13 | """Determine the appropriate command chains""" 14 | for candidate in self.verdict["candidates"]: 15 | match candidate: 16 | case "linux": 17 | self.startup_chains.append(ItchLinuxStartupChain) 18 | case "script": 19 | self.startup_chains.append(ItchScriptStartupChain) 20 | case "java": 21 | self.startup_chains.append(ItchJavaStartupChain) 22 | # TODO implement startup chain for other candidate types 23 | -------------------------------------------------------------------------------- /gali/src/sources/itch/native/itch_native_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.itch.native.itch_native_game import ItchNativeGame 2 | from gali.sources.itch.itch_source import ItchSource 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class ItchNativeSource(ItchSource): 7 | 8 | name: str = "Itch" 9 | game_class: type[ItchNativeGame] = ItchNativeGame 10 | db_path: str = f"{HOME}/.config/itch/db/butler.db" -------------------------------------------------------------------------------- /gali/src/sources/itch/native/itch_script_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.itch.itch_startup_chain import ItchStartupChain 4 | from gali.sources.stemmed_cli_startup_chain import StemmedCLIStartupChain 5 | 6 | class ItchScriptStartupChain(ItchStartupChain, StemmedCLIStartupChain): 7 | 8 | name = "Itch Shell script candidate" 9 | 10 | def get_start_command_suffix(self) -> Iterable[str]: 11 | # TODO start itch script candidate 12 | pass -------------------------------------------------------------------------------- /gali/src/sources/legendary/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/legendary/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/legendary/legendary_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.generic_game import GenericGame 4 | 5 | 6 | @dataclass 7 | class LegendaryGame(GenericGame): 8 | 9 | platform: str = field(default="PC", init=False) 10 | app_name: str = field(default=None) -------------------------------------------------------------------------------- /gali/src/sources/legendary/legendary_source.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from gali.sources.legendary.legendary_game import LegendaryGame 4 | from gali.utils.explicit_config_parser import ExplicitConfigParser 5 | from gali.sources.file_dependent_source import FileDependentSource 6 | 7 | 8 | class LegendarySource(FileDependentSource): 9 | 10 | name: str 11 | config_path: str 12 | default_install_dir: str 13 | game_class: type[LegendaryGame] 14 | 15 | def get_config(self) -> ExplicitConfigParser: 16 | config = ExplicitConfigParser() 17 | config.read(self.config_path) 18 | return config 19 | 20 | def get_installed_json(self, config: ExplicitConfigParser) -> dict: 21 | 22 | # Get path to installed.json 23 | path = config.get( 24 | "Legendary", 25 | "install_dir", 26 | fallback=self.default_install_dir 27 | ) 28 | path = f"{path}/installed.json" 29 | 30 | # Read installed.json 31 | try: 32 | with open(path, "r", encoding="utf-8-sig") as file: 33 | config = json.load(file) 34 | except Exception as err: 35 | raise err 36 | else: 37 | return config 38 | 39 | def get_games(self, installed_json: dict) -> tuple[LegendaryGame]: 40 | 41 | games = [] 42 | 43 | for key in installed_json: 44 | entry = installed_json[key] 45 | 46 | # Skip DLCs 47 | is_dlc = entry.get("is_dlc", False) 48 | if is_dlc: 49 | continue 50 | 51 | # Skip broken entries 52 | name = entry.get("title", None) 53 | app_name = entry.get("app_name", None) 54 | if name is None or app_name is None: 55 | continue 56 | 57 | # Build games 58 | game = self.game_class( 59 | name=name, 60 | app_name=app_name 61 | ) 62 | games.append(game) 63 | 64 | return tuple(games) 65 | 66 | def scan(self) -> tuple[LegendaryGame]: 67 | config = self.get_config() 68 | installed_json = self.get_installed_json(config) 69 | games = self.get_games(installed_json) 70 | return games 71 | 72 | def get_precondition_file_path(self): 73 | return self.config_path 74 | -------------------------------------------------------------------------------- /gali/src/sources/legendary/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/legendary/native/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/legendary/native/legendary_native_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.legendary.legendary_game import LegendaryGame 3 | from gali.sources.legendary.native.legendary_native_startup_chain import LegendaryNativeStartupChain 4 | 5 | 6 | class LegendaryNativeGame(LegendaryGame, Startable): 7 | 8 | startup_chains = [ 9 | LegendaryNativeStartupChain 10 | ] -------------------------------------------------------------------------------- /gali/src/sources/legendary/native/legendary_native_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.legendary.legendary_source import LegendarySource 2 | from gali.sources.legendary.native.legendary_native_game import LegendaryNativeGame 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class LegendaryNativeSource(LegendarySource): 7 | 8 | name: str = "Legendary" 9 | game_class: type[LegendaryNativeGame] = LegendaryNativeGame 10 | config_path: str = f"{HOME}/.config/legendary/config.ini" 11 | default_install_dir: str = f"{HOME}/.config/legendary" -------------------------------------------------------------------------------- /gali/src/sources/legendary/native/legendary_native_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.stemmed_cli_startup_chain import StemmedCLIStartupChain 4 | from gali.sources.legendary.legendary_game import LegendaryGame 5 | 6 | 7 | class LegendaryNativeStartupChain(StemmedCLIStartupChain): 8 | 9 | game: LegendaryGame 10 | name = "Legendary" 11 | stem = ["legendary", "launch"] 12 | 13 | def get_start_command_suffix(self) -> Iterable[str]: 14 | return [self.game.app_name] -------------------------------------------------------------------------------- /gali/src/sources/lutris/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/lutris/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/lutris/lutris_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.generic_game import GenericGame 4 | 5 | 6 | @dataclass 7 | class LutrisGame(GenericGame): 8 | 9 | platform: str = field(default="PC", init=False) 10 | game_slug: str = field(default=None) 11 | config_path: str = field(default=None) -------------------------------------------------------------------------------- /gali/src/sources/lutris/lutris_source.py: -------------------------------------------------------------------------------- 1 | from sqlite3 import connect, Row 2 | 3 | from gali.sources.lutris.lutris_game import LutrisGame 4 | from gali.sources.file_dependent_source import FileDependentSource 5 | 6 | 7 | class LutrisSource(FileDependentSource): 8 | 9 | name: str 10 | db_path: str 11 | game_class: type[LutrisGame] 12 | 13 | db_request: str = """ 14 | SELECT 15 | name, slug, configpath, installed 16 | FROM 17 | 'games' 18 | WHERE 19 | NOT hidden 20 | AND name IS NOT NULL 21 | AND slug IS NOT NULL 22 | AND configPath IS NOT NULL 23 | ; 24 | """ 25 | 26 | def get_db_contents(self) -> list[Row]: 27 | connection = connect(self.db_path) 28 | cursor = connection.execute(self.db_request) 29 | rows = cursor.fetchall() 30 | connection.close() 31 | return rows 32 | 33 | def get_games(self, rows: list[Row]) -> tuple[LutrisGame]: 34 | games = [] 35 | for row in rows: 36 | 37 | name = row[0] 38 | game_slug = row[1] 39 | config_path = row[2] 40 | is_installed = row[3] 41 | 42 | # Skip broken games 43 | if ( 44 | name is None or 45 | game_slug is None or 46 | config_path is None 47 | ): 48 | continue 49 | 50 | game = self.game_class( 51 | name=name, 52 | game_slug=game_slug, 53 | config_path=config_path, 54 | is_installed=is_installed 55 | ) 56 | games.append(game) 57 | 58 | return tuple(games) 59 | 60 | def scan(self) -> tuple[LutrisGame]: 61 | db_rows = self.get_db_contents() 62 | games = self.get_games(db_rows) 63 | return games 64 | 65 | def get_precondition_file_path(self): 66 | return self.db_path -------------------------------------------------------------------------------- /gali/src/sources/lutris/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/lutris/native/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/lutris/native/lutris_native_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.lutris.lutris_game import LutrisGame 3 | from gali.sources.lutris.native.lutris_native_startup_chain import LutrisNativeStartupChain 4 | 5 | 6 | class LutrisNativeGame(LutrisGame, Startable): 7 | 8 | startup_chains = [ 9 | LutrisNativeStartupChain 10 | ] 11 | -------------------------------------------------------------------------------- /gali/src/sources/lutris/native/lutris_native_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.lutris.native.lutris_native_game import LutrisNativeGame 2 | from gali.sources.lutris.lutris_source import LutrisSource 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class LutrisNativeSource(LutrisSource): 7 | 8 | name: str = "Lutris" 9 | game_class: type[LutrisNativeGame] = LutrisNativeGame 10 | db_path: str = f"{HOME}/.local/share/lutris/pga.db" -------------------------------------------------------------------------------- /gali/src/sources/lutris/native/lutris_native_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.script_startup_chain import ScriptStartupChain 2 | from gali.sources.lutris.lutris_game import LutrisGame 3 | from gali.utils.lutris_export_script import lutris_export_script 4 | 5 | class LutrisNativeStartupChain(ScriptStartupChain): 6 | 7 | game: LutrisGame 8 | name = "Lutris" 9 | 10 | def make_script(self) -> None: 11 | """Export the lutris start script to a temp file""" 12 | lutris_export_script(self.game.game_slug, self.tempfile) -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/ppsspp/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/flatpak/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/ppsspp/flatpak/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/flatpak/ppsspp_flatpak_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from gali.sources.startable import Startable 3 | 4 | from gali.sources.ppsspp.ppsspp_game import PPSSPPGame 5 | from gali.sources.ppsspp.flatpak.ppsspp_flatpak_startup_chain import PPSSPPFlatpakStartupChain 6 | 7 | 8 | @dataclass 9 | class PPSSPPFlatpakGame(PPSSPPGame, Startable): 10 | 11 | startup_chains = [ 12 | PPSSPPFlatpakStartupChain 13 | ] -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/flatpak/ppsspp_flatpak_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.ppsspp.flatpak.ppsspp_flatpak_game import PPSSPPFlatpakGame 2 | from gali.sources.ppsspp.ppsspp_source import PPSSPPSource 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class PPSSPPFlatpakSource(PPSSPPSource): 7 | 8 | name: str = "PPSSPP (Flatpak)" 9 | game_class: type[PPSSPPFlatpakGame] = PPSSPPFlatpakGame 10 | config_path: str = f"{HOME}/.var/app/org.ppsspp.PPSSPP/config/ppsspp/PSP/SYSTEM/ppsspp.ini" -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/flatpak/ppsspp_flatpak_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.ppsspp.ppsspp_startup_chain import PPSSPPStartupChain 2 | 3 | 4 | class PPSSPPFlatpakStartupChain(PPSSPPStartupChain): 5 | 6 | name = "PPSSPP Flatpak" 7 | stem = ["flatpak", "run", "org.ppsspp.PPSSPP"] -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/ppsspp/native/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/native/ppsspp_native_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.ppsspp.ppsspp_game import PPSSPPGame 3 | from gali.sources.ppsspp.native.ppsspp_native_startup_chain import PPSSPPNativeStartupChain 4 | 5 | 6 | class PPSSPPNativeGame(PPSSPPGame, Startable): 7 | 8 | startup_chains = [ 9 | PPSSPPNativeStartupChain 10 | ] 11 | -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/native/ppsspp_native_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.ppsspp.ppsspp_source import PPSSPPSource 2 | from gali.sources.ppsspp.native.ppsspp_native_game import PPSSPPNativeGame 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class PPSSPPNativeSource(PPSSPPSource): 7 | 8 | name: str = "PPSSPP" 9 | game_class: type[PPSSPPNativeGame] = PPSSPPNativeGame 10 | config_path: str = f"{HOME}/.config/ppsspp/PSP/SYSTEM/ppsspp.ini" -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/native/ppsspp_native_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.ppsspp.ppsspp_startup_chain import PPSSPPStartupChain 2 | 3 | 4 | class PPSSPPNativeStartupChain(PPSSPPStartupChain): 5 | 6 | name = "PPSSPP SDL frontend" 7 | stem = ["PPSSPPSDL"] -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/ppsspp_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.emulation_game import EmulationGame 4 | 5 | 6 | @dataclass 7 | class PPSSPPGame(EmulationGame): 8 | 9 | platform: str = field(default="Sony - PSP", init=False) 10 | -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/ppsspp_source.py: -------------------------------------------------------------------------------- 1 | from pathlib import PurePath 2 | 3 | from gali.utils.explicit_config_parser import ExplicitConfigParser 4 | from gali.sources.emulation_source import EmulationSource 5 | from gali.sources.game_dir import GameDir 6 | from gali.sources.ppsspp.ppsspp_game import PPSSPPGame 7 | from gali.sources.file_dependent_source import FileDependentSource 8 | 9 | 10 | class PPSSPPSource(EmulationSource, FileDependentSource): 11 | 12 | name: str 13 | config_path: str 14 | game_class: type[PPSSPPGame] 15 | 16 | rom_extensions: tuple[str] = (".iso", ".cso") 17 | 18 | def get_config(self) -> ExplicitConfigParser: 19 | config = ExplicitConfigParser() 20 | config.read(self.config_path, encoding="utf-8-sig") 21 | return config 22 | 23 | def get_rom_dirs(self, config: ExplicitConfigParser) -> tuple[GameDir]: 24 | rom_dirs = [] 25 | items = config.items("PinnedPaths") 26 | for (key, path) in items: 27 | rom_dirs.append(GameDir(path, 0)) 28 | return tuple(rom_dirs) 29 | 30 | def get_rom_games(self, rom_dirs: tuple[GameDir]) -> tuple[PPSSPPGame]: 31 | games = [] 32 | for rom_dir in rom_dirs: 33 | try: 34 | rom_paths = self.get_rom_paths(rom_dir, self.rom_extensions) 35 | except OSError: 36 | continue 37 | for path in rom_paths: 38 | name = PurePath(path).name 39 | game = self.game_class( 40 | name=name, 41 | game_path=path, 42 | is_installed=True, 43 | ) 44 | games.append(game) 45 | return tuple(games) 46 | 47 | def scan(self) -> list[PPSSPPGame]: 48 | config = self.get_config() 49 | rom_dirs = self.get_rom_dirs(config) 50 | rom_games = self.get_rom_games(rom_dirs) 51 | return rom_games 52 | 53 | def get_precondition_file_path(self): 54 | return self.config_path -------------------------------------------------------------------------------- /gali/src/sources/ppsspp/ppsspp_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.stemmed_cli_startup_chain import StemmedCLIStartupChain 4 | from gali.sources.ppsspp.ppsspp_game import PPSSPPGame 5 | 6 | 7 | class PPSSPPStartupChain(StemmedCLIStartupChain): 8 | 9 | game: PPSSPPGame 10 | 11 | def get_start_command_suffix(self) -> Iterable[str]: 12 | return [self.game.game_path] -------------------------------------------------------------------------------- /gali/src/sources/retroarch/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/retroarch/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/retroarch/flatpak/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/retroarch/flatpak/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/retroarch/flatpak/retroarch_flatpak_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.retroarch.retroarch_game import RetroarchGame 3 | from gali.sources.retroarch.flatpak.retroarch_flatpak_startup_chain import RetroarchFlatpakStartupChain 4 | 5 | class RetroarchFlatpakGame(RetroarchGame, Startable): 6 | 7 | startup_chains = [ 8 | RetroarchFlatpakStartupChain 9 | ] -------------------------------------------------------------------------------- /gali/src/sources/retroarch/flatpak/retroarch_flatpak_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.retroarch.flatpak.retroarch_flatpak_game import RetroarchFlatpakGame 2 | from gali.sources.retroarch.retroarch_source import RetroarchSource 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class RetroarchFlatpakSource(RetroarchSource): 7 | 8 | name: str = "Retroarch (Flatpak)" 9 | game_class: type[RetroarchFlatpakGame] = RetroarchFlatpakGame 10 | config_path: str = f"{HOME}/.var/app/org.libretro.RetroArch/config/retroarch/retroarch.cfg" -------------------------------------------------------------------------------- /gali/src/sources/retroarch/flatpak/retroarch_flatpak_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.retroarch.retroarch_startup_chain import RetroarchStartupChain 2 | 3 | 4 | class RetroarchFlatpakStartupChain(RetroarchStartupChain): 5 | 6 | name = "Retroarch Flatpak" 7 | stem = ["flatpak", "run", "org.libretro.RetroArch", "--libretro"] -------------------------------------------------------------------------------- /gali/src/sources/retroarch/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/retroarch/native/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/retroarch/native/retroarch_native_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.retroarch.retroarch_game import RetroarchGame 3 | from gali.sources.retroarch.native.retroarch_native_startup_chain import RetroarchNativeStartupChain 4 | 5 | 6 | class RetroarchNativeGame(RetroarchGame, Startable): 7 | 8 | startup_chains = [ 9 | RetroarchNativeStartupChain 10 | ] -------------------------------------------------------------------------------- /gali/src/sources/retroarch/native/retroarch_native_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.retroarch.native.retroarch_native_game import RetroarchNativeGame 2 | from gali.sources.retroarch.retroarch_source import RetroarchSource 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class RetroarchNativeSource(RetroarchSource): 7 | 8 | name: str = "Retroarch" 9 | game_class: type[RetroarchNativeGame] = RetroarchNativeGame 10 | config_path: str = f"{HOME}/.config/retroarch/retroarch.cfg" -------------------------------------------------------------------------------- /gali/src/sources/retroarch/native/retroarch_native_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.retroarch.retroarch_startup_chain import RetroarchStartupChain 2 | 3 | 4 | class RetroarchNativeStartupChain(RetroarchStartupChain): 5 | 6 | name = "Retroarch" 7 | stem = ["retroarch", "--libretro"] -------------------------------------------------------------------------------- /gali/src/sources/retroarch/retroarch_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.emulation_game import EmulationGame 4 | 5 | 6 | @dataclass 7 | class RetroarchGame(EmulationGame): 8 | 9 | core_path: str = field(default=None) -------------------------------------------------------------------------------- /gali/src/sources/retroarch/retroarch_source.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from pathlib import PurePath 4 | 5 | from gali.sources.retroarch.retroarch_game import RetroarchGame 6 | from gali.utils.cfg_parser import CfgParser 7 | from gali.sources.file_dependent_source import FileDependentSource 8 | 9 | 10 | class RetroarchSource(FileDependentSource): 11 | 12 | name: str 13 | config_path: str 14 | game_class: type[RetroarchGame] 15 | 16 | def get_config(self) -> CfgParser: 17 | config = CfgParser() 18 | config.read(self.config_path) 19 | return config 20 | 21 | def get_playlist_paths(self, config: CfgParser) -> tuple[str]: 22 | 23 | # Get playlist dir 24 | playlists_dir: str = config.get("playlist_directory", None) 25 | if playlists_dir is None: 26 | raise KeyError() 27 | 28 | # Handle "~" for user home 29 | playlists_dir = os.path.expanduser(playlists_dir) 30 | 31 | def filter_fn(dirent: os.DirEntry): 32 | if not dirent.is_file(): 33 | return False 34 | if not dirent.name.endswith(".lpl"): 35 | return False 36 | return True 37 | 38 | # Get playlist paths 39 | iterator = os.scandir(playlists_dir) 40 | playlists = tuple(filter(filter_fn, iterator)) 41 | return playlists 42 | 43 | def get_games(self, playlist_paths: tuple[str]) -> tuple[RetroarchGame]: 44 | 45 | games = [] 46 | 47 | for playlist_path in playlist_paths: 48 | 49 | # Get playlist data 50 | try: 51 | file = open(playlist_path, encoding="utf-8-sig") 52 | playlist = json.load(file) 53 | except OSError: 54 | continue 55 | else: 56 | file.close() 57 | 58 | # Get playlist platform 59 | platform = PurePath(playlist_path).stem 60 | 61 | # Get default core for the playlist 62 | pl_core_path = playlist.get("default_core_path", "") 63 | if len(pl_core_path) == 0: # Handle empty string 64 | pl_core_path = None 65 | 66 | # Get games 67 | items = playlist.get("items", list()) 68 | for item in items: 69 | 70 | # Skip broken games 71 | name = item.get("label", None) 72 | game_path = item.get("path", None) 73 | core_path = item.get("core_path", pl_core_path) 74 | if ( 75 | name is None or 76 | game_path is None or 77 | core_path is None 78 | ): 79 | continue 80 | 81 | # Build games 82 | game = self.game_class( 83 | name=name, 84 | game_path=game_path, 85 | core_path=core_path, 86 | platform=platform 87 | ) 88 | games.append(game) 89 | 90 | return tuple(games) 91 | 92 | def scan(self) -> tuple[RetroarchGame]: 93 | config = self.get_config() 94 | playlist_paths = self.get_playlist_paths(config) 95 | games = self.get_games(playlist_paths) 96 | return games 97 | 98 | def get_precondition_file_path(self): 99 | return self.config_path 100 | -------------------------------------------------------------------------------- /gali/src/sources/retroarch/retroarch_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.stemmed_cli_startup_chain import StemmedCLIStartupChain 4 | from gali.sources.retroarch.retroarch_game import RetroarchGame 5 | 6 | 7 | class RetroarchStartupChain(StemmedCLIStartupChain): 8 | 9 | game: RetroarchGame 10 | 11 | def get_start_command_suffix(self) -> Iterable[str]: 12 | return [self.game.core_path, self.game.game_path] -------------------------------------------------------------------------------- /gali/src/sources/scannable.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Union, Any, Iterable 3 | 4 | 5 | class UnscannableReason(): 6 | """ 7 | Semantic reason for a Scannable to not be scannable. 8 | Every instance is falsy. 9 | """ 10 | 11 | message: str 12 | 13 | def __init__(self, message: str): 14 | self.message = message 15 | 16 | def __str__(self) -> str: 17 | return self.message 18 | 19 | def __bool__(self) -> bool: 20 | return False 21 | 22 | 23 | class Scannable(ABC): 24 | """ 25 | Abstract class representing a scannable (eg. a Source). 26 | Implementations must specify the scan method. 27 | Implementations should specify the is_scannable method for more clarity to 28 | the end user. 29 | """ 30 | 31 | def scan(self) -> Iterable[Any]: 32 | """Start a scan, returning an iterable""" 33 | return list() 34 | 35 | def is_scannable(self) -> Union[bool, UnscannableReason]: 36 | """ 37 | Check if self is scannable. 38 | 39 | Return value : 40 | - If scannable -> True 41 | - Else -> a falsy reason 42 | """ 43 | return True -------------------------------------------------------------------------------- /gali/src/sources/script_startup_chain.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Iterable 3 | from tempfile import mkstemp 4 | from os import remove 5 | 6 | from gali.sources.cli_startup_chain import CLIStartupChain 7 | 8 | 9 | class ScriptStartupChain(CLIStartupChain): 10 | """Class representing a startup chain that starts a game from generated shell script in a subprocess""" 11 | 12 | tempfile: str 13 | 14 | @abstractmethod 15 | def make_script(self) -> None: 16 | """Make the shell script that will be started""" 17 | pass 18 | 19 | def prepare(self) -> None: 20 | """Create a temp file ready to contain a shell script""" 21 | (_, path) = mkstemp() 22 | self.tempfile = path 23 | self.make_script() 24 | 25 | def get_start_command(self) -> Iterable[str]: 26 | """Get the start command""" 27 | return ("sh", self.tempfile) 28 | 29 | def cleanup(self) -> None: 30 | """Delete the temp file""" 31 | 32 | # ! DEBG 33 | return 34 | 35 | try: 36 | remove(self.tempfile) 37 | except FileNotFoundError: 38 | # If the file doesn't exist, nothing to do. 39 | pass -------------------------------------------------------------------------------- /gali/src/sources/source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.game import Game 2 | from gali.sources.scannable import Scannable 3 | 4 | class Source(Scannable): 5 | 6 | name: str 7 | game_class: type[Game] 8 | prefer_cache: bool = False -------------------------------------------------------------------------------- /gali/src/sources/startable.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Sequence 3 | 4 | from gali.sources.startup_chain import StartupChain 5 | 6 | 7 | class Startable(ABC): 8 | """Class representing a startable object""" 9 | 10 | startup_chains: Sequence[type[StartupChain]] -------------------------------------------------------------------------------- /gali/src/sources/startup_chain.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | from gali.sources.game import Game 5 | 6 | 7 | class StartupChain(ABC): 8 | """Class representing a startup chain for a startable object. 9 | 10 | A startup chain represents a broad method of starting a game of a certain type. 11 | 12 | The goal is to present multiple launch methods to the end user, 13 | think of the Steam popup asking if you want to launch with DirectX or Vulkan. 14 | 15 | Don't use a different startup chain for small options change like fullscreen on/off. 16 | In that case, specify launch options for the user that will be passed to the StartupChain. 17 | 18 | The order in which the steps execute is: 19 | 1. prepare 20 | 2. start 21 | 3. (game terminates) 22 | 4. cleanup 23 | """ 24 | 25 | name: str 26 | game: Game 27 | options: dict[str, Any] 28 | 29 | def __init__(self, game: Game, options: dict[str, Any]) -> None: 30 | """Create a startup chain. 31 | 32 | Keys for options are strings, values are of any type. 33 | The available key and value types are specific to every startup chain.""" 34 | super().__init__() 35 | self.game = game 36 | self.options = options 37 | 38 | def prepare(self) -> None: 39 | """Method to run before starting the game""" 40 | pass 41 | 42 | @abstractmethod 43 | def start(self) -> None: 44 | """Start the game""" 45 | pass 46 | 47 | def cleanup(self) -> None: 48 | """Method to run after the game has terminated""" 49 | pass -------------------------------------------------------------------------------- /gali/src/sources/steam/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/steam/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/steam/flatpak/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/steam/flatpak/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/steam/flatpak/steam_flatpak_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.steam.steam_source import SteamSource 2 | from gali.sources.steam.steam_xdg_game import SteamXDGGame 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class SteamFlatpakSource(SteamSource): 7 | 8 | name: str = "Steam (Flatpak)" 9 | game_class: type[SteamXDGGame] = SteamXDGGame 10 | steam_dir: str = f"{HOME}/.var/app/com.valvesoftware.Steam/.local/share/Steam" 11 | -------------------------------------------------------------------------------- /gali/src/sources/steam/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/steam/native/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/steam/native/steam_native_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.steam.steam_source import SteamSource 2 | from gali.sources.steam.steam_xdg_game import SteamXDGGame 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class SteamNativeSource(SteamSource): 7 | 8 | name: str = "Steam" 9 | game_class: type[SteamXDGGame] = SteamXDGGame 10 | steam_dir: str = f"{HOME}/.local/share/Steam" -------------------------------------------------------------------------------- /gali/src/sources/steam/steam_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.generic_game import GenericGame 4 | 5 | 6 | @dataclass 7 | class SteamGame(GenericGame): 8 | 9 | platform: str = field(default="PC", init=False) 10 | app_id: str = field(default=None) 11 | -------------------------------------------------------------------------------- /gali/src/sources/steam/steam_source.py: -------------------------------------------------------------------------------- 1 | import vdf 2 | import os 3 | import re 4 | 5 | from gali.sources.steam.steam_game import SteamGame 6 | from gali.sources.file_dependent_source import FileDependentSource 7 | 8 | 9 | class InvalidManifestException(Exception): 10 | pass 11 | 12 | 13 | class NonGameManifestException(Exception): 14 | pass 15 | 16 | 17 | def fullmatch_any(value: str, patterns: tuple[re.Pattern]) -> bool: 18 | """Test if a string matches any regex in a tuple""" 19 | for pattern in patterns: 20 | if pattern.fullmatch(value): 21 | return True 22 | return False 23 | 24 | 25 | class SteamSource(FileDependentSource): 26 | 27 | name: str 28 | steam_dir: str 29 | game_class: type[SteamGame] 30 | 31 | rel_library_config: str = "config/libraryfolders.vdf" 32 | rel_image_cache: str = "appcache/librarycache" 33 | installed_mask = 4 34 | ignored_appids = (221410, 228980, 1070560) 35 | ignored_name_patterns = ( 36 | re.compile("^Steamworks.*"), 37 | re.compile("^(S|s)team ?(L|l)inux ?(R|r)untime.*"), 38 | re.compile("^Proton.*"), 39 | ) 40 | 41 | def get_library_config(self) -> dict: 42 | path = f"{self.steam_dir}/{self.rel_library_config}" 43 | try: 44 | with open(path, "r", encoding="utf-8-sig") as file: 45 | config = vdf.load(file) 46 | except Exception as err: 47 | raise err 48 | else: 49 | return config 50 | 51 | def get_dir_paths(self, config: dict) -> tuple[str]: 52 | paths = [] 53 | library_folders = config["libraryfolders"] 54 | if library_folders is None: 55 | raise KeyError() 56 | for key in library_folders: 57 | entry = library_folders[key] 58 | path = entry.get("path", None) 59 | if path is None: 60 | continue 61 | paths.append(path) 62 | return tuple(paths) 63 | 64 | def get_manifest_paths(self, game_dirs: list[str]) -> list[str]: 65 | 66 | mainfest_paths = [] 67 | 68 | # Get all manifest paths 69 | for game_dir in game_dirs: 70 | 71 | # Test if dir is scannable 72 | path = f"{game_dir}/steamapps" 73 | if not os.access(path, os.R_OK): 74 | continue 75 | 76 | # Scan dir 77 | for dirent in os.scandir(path): 78 | if not dirent.is_file(): 79 | continue 80 | if not dirent.name.startswith("appmanifest_"): 81 | continue 82 | if not dirent.name.endswith(".acf"): 83 | continue 84 | mainfest_paths.append(dirent.path) 85 | 86 | return mainfest_paths 87 | 88 | def read_manifest(self, manifest_path: str) -> SteamGame: 89 | 90 | # Get data 91 | try: 92 | with open(manifest_path, "r", encoding="utf-8-sig") as file: 93 | data = vdf.load(file) 94 | except Exception as err: 95 | raise err 96 | 97 | # Get installation state 98 | app_state = data.get("AppState", None) 99 | if app_state is None: 100 | raise InvalidManifestException("Missing AppState") 101 | state_flags = int(app_state.get("StateFlags", 0)) 102 | is_installed = state_flags & self.installed_mask 103 | 104 | # Get appid 105 | app_id = app_state.get("appid", None) 106 | if app_id is None: 107 | raise InvalidManifestException("Missing appid") 108 | 109 | # Get name 110 | name = app_state.get("name", None) 111 | if name is None: 112 | raise InvalidManifestException("Missing name") 113 | 114 | # Skip known non-games 115 | if app_id in self.ignored_appids: 116 | raise NonGameManifestException("AppId in ignore list") 117 | if fullmatch_any(name, self.ignored_name_patterns): 118 | raise NonGameManifestException("Name matches an ignore pattern") 119 | 120 | # Build game 121 | game = self.game_class( 122 | name=name, 123 | app_id=app_id, 124 | is_installed=is_installed 125 | ) 126 | return game 127 | 128 | def get_games(self, manifest_paths: tuple[str]) -> tuple[SteamGame]: 129 | 130 | games = [] 131 | 132 | # Read all manifests 133 | for manifest_path in manifest_paths: 134 | 135 | # Build game 136 | try: 137 | game = self.read_manifest(manifest_path) 138 | except Exception: 139 | continue 140 | 141 | # If an existing game has the same app id, 142 | # if the new is installed and not the old, 143 | # remove the old. 144 | for (i, old) in enumerate(games): 145 | if ( 146 | old.app_id == game.app_id 147 | and not old.is_installed 148 | and game.is_installed 149 | ): 150 | del games[i] 151 | 152 | games.append(game) 153 | 154 | return tuple(games) 155 | 156 | def scan(self) -> tuple[SteamGame]: 157 | config = self.get_library_config() 158 | game_dir_paths = self.get_dir_paths(config) 159 | manifest_paths = self.get_manifest_paths(game_dir_paths) 160 | games = self.get_games(manifest_paths) 161 | return games 162 | 163 | def get_precondition_file_path(self): 164 | return f"{self.steam_dir}/{self.rel_library_config}" -------------------------------------------------------------------------------- /gali/src/sources/steam/steam_xdg_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.steam.steam_game import SteamGame 3 | from gali.sources.steam.steam_xdg_startup_chain import SteamXDGStartupChain 4 | 5 | 6 | class SteamXDGGame(SteamGame, Startable): 7 | 8 | startup_chains = [ 9 | SteamXDGStartupChain 10 | ] 11 | -------------------------------------------------------------------------------- /gali/src/sources/steam/steam_xdg_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.stemmed_cli_startup_chain import StemmedCLIStartupChain 4 | from gali.sources.steam.steam_game import SteamGame 5 | 6 | 7 | class SteamXDGStartupChain(StemmedCLIStartupChain): 8 | 9 | game: SteamGame 10 | name = "Steam" 11 | stem = "xdg-open" 12 | 13 | def get_start_command_suffix(self) -> Iterable[str]: 14 | return [f"steam://rungameid/{self.game.app_id}"] -------------------------------------------------------------------------------- /gali/src/sources/stemmed_cli_startup_chain.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Iterable, Sequence 3 | 4 | from gali.sources.cli_startup_chain import CLIStartupChain 5 | 6 | 7 | class StemmedCLIStartupChain(CLIStartupChain): 8 | 9 | stem: Sequence[str] = list() 10 | 11 | @abstractmethod 12 | def get_start_command_suffix(self) -> Iterable[str]: 13 | """Get the part that comes after the stem of the start command""" 14 | pass 15 | 16 | def get_start_command(self) -> Iterable[str]: 17 | command: list[str] = list() 18 | command.extend(self.stem) 19 | command.extend(self.get_start_command_suffix()) 20 | return command -------------------------------------------------------------------------------- /gali/src/sources/yuzu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/yuzu/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/yuzu/flatpak/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/yuzu/flatpak/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/yuzu/flatpak/yuzu_flatpak_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.yuzu.yuzu_game import YuzuGame 3 | from gali.sources.yuzu.flatpak.yuzu_flatpak_startup_chain import YuzuFlatpakStartupChain 4 | 5 | 6 | class YuzuFlatpakGame(YuzuGame, Startable): 7 | 8 | startup_chains = [ 9 | YuzuFlatpakStartupChain 10 | ] -------------------------------------------------------------------------------- /gali/src/sources/yuzu/flatpak/yuzu_flatpak_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.yuzu.flatpak.yuzu_flatpak_game import YuzuFlatpakGame 2 | from gali.sources.yuzu.yuzu_source import YuzuSource 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class YuzuFlatpakSource(YuzuSource): 7 | 8 | name: str = "Yuzu (Flatpak)" 9 | game_class: type[YuzuFlatpakGame] = YuzuFlatpakGame 10 | config_path: str = f"{HOME}/.var/app/org.yuzu_emu.yuzu/config/yuzu/qt-config.ini" -------------------------------------------------------------------------------- /gali/src/sources/yuzu/flatpak/yuzu_flatpak_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.yuzu.yuzu_startup_chain import YuzuStartupChain 2 | 3 | 4 | class YuzuFlatpakStartupChain(YuzuStartupChain): 5 | 6 | name = "Yuzu Flatpak" 7 | stem = ["flatpak", "run", "org.yuzu_emu.yuzu"] -------------------------------------------------------------------------------- /gali/src/sources/yuzu/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/sources/yuzu/native/__init__.py -------------------------------------------------------------------------------- /gali/src/sources/yuzu/native/yuzu_native_game.py: -------------------------------------------------------------------------------- 1 | from gali.sources.startable import Startable 2 | from gali.sources.yuzu.yuzu_game import YuzuGame 3 | from gali.sources.yuzu.native.yuzu_native_startup_chain import YuzuNativeStartupChain 4 | 5 | 6 | class YuzuNativeGame(YuzuGame, Startable): 7 | 8 | startup_chains = [ 9 | YuzuNativeStartupChain 10 | ] 11 | -------------------------------------------------------------------------------- /gali/src/sources/yuzu/native/yuzu_native_source.py: -------------------------------------------------------------------------------- 1 | from gali.sources.yuzu.yuzu_source import YuzuSource 2 | from gali.sources.yuzu.native.yuzu_native_game import YuzuNativeGame 3 | from gali.utils.locations import HOME 4 | 5 | 6 | class YuzuNativeSource(YuzuSource): 7 | 8 | name: str = "Yuzu" 9 | game_class: type[YuzuNativeGame] = YuzuNativeGame 10 | config_path: str = f"{HOME}/.config/yuzu/qt-config.ini" -------------------------------------------------------------------------------- /gali/src/sources/yuzu/native/yuzu_native_startup_chain.py: -------------------------------------------------------------------------------- 1 | from gali.sources.yuzu.yuzu_startup_chain import YuzuStartupChain 2 | 3 | 4 | class YuzuNativeStartupChain(YuzuStartupChain): 5 | 6 | name = "Yuzu" 7 | stem = ["yuzu"] -------------------------------------------------------------------------------- /gali/src/sources/yuzu/yuzu_game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from gali.sources.emulation_game import EmulationGame 4 | 5 | 6 | @dataclass 7 | class YuzuGame(EmulationGame): 8 | 9 | platform: str = field(default="Nintendo - Switch", init=False) 10 | -------------------------------------------------------------------------------- /gali/src/sources/yuzu/yuzu_source.py: -------------------------------------------------------------------------------- 1 | from math import inf 2 | from pathlib import PurePath 3 | 4 | from gali.sources.yuzu.yuzu_game import YuzuGame 5 | from gali.utils.explicit_config_parser import ExplicitConfigParser 6 | from gali.sources.emulation_source import EmulationSource 7 | from gali.sources.game_dir import GameDir 8 | from gali.sources.file_dependent_source import FileDependentSource 9 | 10 | 11 | class YuzuSource(EmulationSource, FileDependentSource): 12 | 13 | name: str 14 | config_path: str 15 | game_class: type[YuzuGame] 16 | 17 | rom_extensions: tuple[str] = (".xci", ".nsp", ".nso", ".nro") 18 | 19 | def get_config(self) -> ExplicitConfigParser: 20 | config = ExplicitConfigParser() 21 | config.read(self.config_path, encoding="utf-8-sig") 22 | return config 23 | 24 | def get_rom_dirs(self, config: ExplicitConfigParser) -> tuple[GameDir]: 25 | rom_dirs = [] 26 | n_dirs = config.getint( 27 | "UI", 28 | r"Paths\gamedirs\size", 29 | fallback=0 30 | ) 31 | for i in range(1, n_dirs + 1): 32 | deep = config.getboolean( 33 | "UI", 34 | f"Paths\\gamedirs\\{i}\\deep_scan", 35 | fallback=False 36 | ) 37 | path = config.get( 38 | "UI", 39 | f"Paths\\gamedirs\\{i}\\path", 40 | fallback=None 41 | ) 42 | if path is None: 43 | continue 44 | if path in ("SDMC", "UserNAND", "SysNAND"): 45 | continue 46 | depth = inf if deep else 0 47 | rom_dirs.append(GameDir(path, depth)) 48 | return tuple(rom_dirs) 49 | 50 | def get_rom_games(self, rom_dirs: tuple[GameDir]) -> tuple[YuzuGame]: 51 | games = [] 52 | for rom_dir in rom_dirs: 53 | rom_paths = [] 54 | try: 55 | rom_paths = self.get_rom_paths(rom_dir, self.rom_extensions) 56 | except OSError: 57 | continue 58 | for path in rom_paths: 59 | name = PurePath(path).name 60 | game = self.game_class( 61 | name=name, 62 | game_path=path, 63 | is_installed=True, 64 | ) 65 | games.append(game) 66 | return tuple(games) 67 | 68 | def scan(self) -> tuple[YuzuGame]: 69 | config = self.get_config() 70 | rom_dirs = self.get_rom_dirs(config) 71 | rom_games = self.get_rom_games(rom_dirs) 72 | return rom_games 73 | 74 | def get_precondition_file_path(self): 75 | return self.config_path 76 | -------------------------------------------------------------------------------- /gali/src/sources/yuzu/yuzu_startup_chain.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from gali.sources.stemmed_cli_startup_chain import StemmedCLIStartupChain 4 | from gali.sources.yuzu.yuzu_game import YuzuGame 5 | 6 | 7 | class YuzuStartupChain(StemmedCLIStartupChain): 8 | 9 | game: YuzuGame 10 | 11 | def get_start_command(self) -> Iterable[str]: 12 | return [self.game.game_path] -------------------------------------------------------------------------------- /gali/src/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/ui/__init__.py -------------------------------------------------------------------------------- /gali/src/ui/application.py: -------------------------------------------------------------------------------- 1 | import gi 2 | gi.require_version("Gtk", "4.0") 3 | gi.require_version("Adw", "1") 4 | from gi.repository import Gtk, Gio, Adw 5 | 6 | from gali.ui.application_window import ApplicationWindow 7 | import gali.singletons as singletons 8 | 9 | class Application(Adw.Application): 10 | """The main application singleton class.""" 11 | 12 | games_store = None 13 | 14 | def __init__(self): 15 | super().__init__( 16 | application_id="com.github.geoffreycoulaud.gali", 17 | flags=Gio.ApplicationFlags.FLAGS_NONE, 18 | ) 19 | singletons.library.gio_list_store.connect("notify::n-items", self.on_library_size_change) 20 | self.create_action("quit", self.quit, ["q"]) 21 | self.create_action("scan", self.on_scan) 22 | self.create_action("about", self.on_about) 23 | self.create_action("preferences", self.on_preferences) 24 | self.create_action("start-game-requested", self.on_start_game_requested) 25 | self.create_action("stop-game-requested", self.on_stop_game_requested) 26 | self.create_action("kill-game-requested", self.on_kill_game_requested) 27 | 28 | def do_activate(self): 29 | window = self.props.active_window 30 | if not window: window = ApplicationWindow(application=self) 31 | window.present() 32 | 33 | def on_scan(self, *data): 34 | # TODO Display a scanning cancellable toast 35 | singletons.library.scan() 36 | 37 | def on_about(self, *data): 38 | builder = Gtk.Builder.new_from_resource(resource_path="/com/github/geoffreycoulaud/gali/ui/templates/about_window.ui") 39 | about_window = builder.get_object("about_window") 40 | about_window.set_transient_for(self.props.active_window) 41 | about_window.present() 42 | 43 | def on_preferences(self, *data): 44 | print("Preferences action") 45 | 46 | def on_library_size_change(self, *data): 47 | """ 48 | Handle change of number of items in the library 49 | """ 50 | n_items = singletons.library.gio_list_store.get_n_items() 51 | window = self.props.active_window 52 | if n_items == 0: window.set_active_view("no_games_view") 53 | else: window.set_active_view("games_view") 54 | 55 | def on_start_game_requested(self, *data): 56 | singletons.launcher.start() 57 | 58 | def on_stop_game_requested(self, *data): 59 | singletons.launcher.terminate() 60 | 61 | def on_kill_game_requested(self, *data): 62 | builder = Gtk.Builder.new_from_resource(resource_path="/com/github/geoffreycoulaud/gali/ui/templates/kill_game_confirm_dialog.ui") 63 | dialog = builder.get_object("kill_game_confirm_dialog") 64 | dialog.connect("response", self.on_kill_game_confirm_response) 65 | dialog.set_transient_for(self.props.active_window) 66 | dialog.present() 67 | 68 | def on_kill_game_confirm_response(self, dialog: Adw.MessageDialog, response: str, *data): 69 | if not response == "kill": return 70 | singletons.launcher.terminate(force=True) 71 | 72 | def create_action(self, name, callback, shortcuts=None): 73 | """Add an application action. 74 | 75 | Args: 76 | name: the name of the action 77 | callback: the function to be called when the action is activated 78 | shortcuts: an optional list of accelerators 79 | """ 80 | action = Gio.SimpleAction.new(name, None) 81 | action.connect("activate", callback) 82 | self.add_action(action) 83 | if shortcuts: 84 | self.set_accels_for_action(f"app.{name}", shortcuts) 85 | -------------------------------------------------------------------------------- /gali/src/ui/application_window.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk, Adw 2 | import gali.singletons as singletons 3 | 4 | # Must be processed by GTK first, needed in application_window.ui 5 | from gali.ui.games_view import GamesView 6 | from gali.ui.games_details import GameDetails 7 | from gali.ui.filter_popover import FilterPopover 8 | from gali.ui.game_life_cycle_controls import GameLifeCycleControls 9 | 10 | 11 | @Gtk.Template(resource_path="/com/github/geoffreycoulaud/gali/ui/templates/application_window.ui") 12 | class ApplicationWindow(Adw.ApplicationWindow): 13 | __gtype_name__ = "GaliApplicationWindow" 14 | 15 | flap = Gtk.Template.Child("flap") 16 | view_stack = Gtk.Template.Child("view_stack") 17 | games_view = Gtk.Template.Child("games_view") 18 | game_details = Gtk.Template.Child("game_details") 19 | 20 | def __init__(self, application): 21 | super().__init__(application=application) 22 | singletons.library.gtk_selection_model.connect("selection-changed", self.on_selection_change) 23 | 24 | def set_active_view(self, visible_child_name): 25 | """Change the active view in the stack. 26 | 27 | Available options are 28 | - no_games 29 | - games 30 | """ 31 | self.view_stack.set_visible_child_name(visible_child_name) 32 | 33 | def on_selection_change(self, selection_model, *args): 34 | """Handle the selected game changing""" 35 | # Find selected game 36 | n_items = singletons.library.gio_list_store.get_n_items() 37 | selected_item = None 38 | for i in range(n_items): 39 | is_selected = singletons.library.gtk_selection_model.is_selected(i) 40 | if not is_selected: continue 41 | selected_item = singletons.library.gio_list_store.get_item(i) 42 | break 43 | 44 | # Hide flap is none selected 45 | if selected_item is None: 46 | self.flap.set_reveal_flap(False) 47 | return 48 | 49 | # Show flap with data 50 | self.flap.set_reveal_flap(True) 51 | self.game_details.set_game(selected_item.game) 52 | 53 | # Update launcher game 54 | singletons.launcher.set_game(selected_item.game) 55 | 56 | def on_game_before_start(self, *args): 57 | """Handle game about to start""" 58 | # TODO lock game selection 59 | # TODO flap grabs all clicks can't be closed 60 | print("Game starting") 61 | 62 | def on_game_stopped(self, *args): 63 | """Handle game stopped""" 64 | # TODO unlock game selection 65 | # TODO revert flap to normal 66 | print("Game closed") -------------------------------------------------------------------------------- /gali/src/ui/filter_popover.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | from gali.sources.source import Source 3 | from gali.sources.all_sources import all_sources 4 | import gali.singletons as singletons 5 | 6 | class FilterPopover(Gtk.Popover): 7 | """A class representing the games filtering Popover""" 8 | 9 | __gtype_name__ = "GaliFilterPopover" 10 | 11 | button_source_map: dict[Gtk.CheckButton, type[Source]] = dict() 12 | 13 | def __init__(self) -> None: 14 | super().__init__() 15 | box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 16 | for klass in all_sources: 17 | if not klass().is_scannable(): continue 18 | button = Gtk.CheckButton.new_with_label(klass.name) 19 | self.button_source_map[button] = klass 20 | button.set_active(True) 21 | button.connect("toggled", self.on_check_toggled) 22 | box.append(button) 23 | self.set_child(box) 24 | 25 | def on_check_toggled(self, button: Gtk.CheckButton): 26 | """Update the visibility a source when its filter button changes state""" 27 | if button not in self.button_source_map.keys(): 28 | raise Exception("Unknown filter button toggled") 29 | klass = self.button_source_map[button] 30 | if button.get_active(): 31 | singletons.library.show_source(klass) 32 | else: 33 | singletons.library.hide_source(klass) -------------------------------------------------------------------------------- /gali/src/ui/game_life_cycle_controls.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | @Gtk.Template(resource_path="/com/github/geoffreycoulaud/gali/ui/templates/game_life_cycle_controls.ui") 4 | class GameLifeCycleControls(Gtk.Box): 5 | __gtype_name__ = "GaliGameLifeCycleControls" -------------------------------------------------------------------------------- /gali/src/ui/games_details.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | from gali.sources.game import Game 3 | 4 | @Gtk.Template(resource_path="/com/github/geoffreycoulaud/gali/ui/templates/game_details.ui") 5 | class GameDetails(Gtk.Box): 6 | __gtype_name__ = "GaliGameDetails" 7 | 8 | title = Gtk.Template.Child("name") 9 | 10 | def __init__(self) -> None: 11 | super().__init__() 12 | 13 | def set_game(self, game: Game) -> None: 14 | """Set the game displayed""" 15 | self.title.set_label(game.name) -------------------------------------------------------------------------------- /gali/src/ui/games_view.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | import gali.singletons as singletons 3 | 4 | class GamesViewFactoryBuilder(): 5 | """Utility class to create a SignalListItemFactory used to display games""" 6 | 7 | @staticmethod 8 | def build_factory() -> Gtk.SignalListItemFactory: 9 | """Create a new SignalListItemFactory connected to this class' signal handlers""" 10 | factory = Gtk.SignalListItemFactory() 11 | factory.connect("setup", GamesViewFactoryBuilder.on_setup) 12 | factory.connect("bind", GamesViewFactoryBuilder.on_bind) 13 | factory.connect("unbind", GamesViewFactoryBuilder.on_unbind) 14 | factory.connect("teardown", GamesViewFactoryBuilder.on_teardown) 15 | return factory 16 | 17 | @staticmethod 18 | def on_setup(widget: Gtk.ListView, list_item: Gtk.ListItem): 19 | """ 20 | Callback for the setup signal 21 | In charge of creating the inner structure of the ListItem widget. 22 | """ 23 | label = Gtk.Label() 24 | list_item.set_child(label) 25 | 26 | @staticmethod 27 | def on_bind(widget: Gtk.ListView, list_item: Gtk.ListItem): 28 | """ 29 | Callback for the bind signal 30 | In charge of finalizing the widget's content and signals just before 31 | it is presented (at creation or reuse) 32 | """ 33 | label = list_item.get_child() 34 | game_gobject = list_item.get_item() 35 | label.set_label(str(game_gobject)) 36 | 37 | @staticmethod 38 | def on_unbind(widget: Gtk.ListView, list_item: Gtk.ListItem): 39 | """ 40 | Callback for the unbind signal 41 | In charge of undoing the bind step. It is called before bind at reuse 42 | and before teardown at destroy time. 43 | """ 44 | # Nothing to do here 45 | pass 46 | 47 | @staticmethod 48 | def on_teardown(widget: Gtk.ListView, list_item: Gtk.ListItem): 49 | """ 50 | Callback for the teardown signal 51 | In charge of undoing the setup step. Used to free the inner structure 52 | to set the widgets' reference count to 0. 53 | """ 54 | list_item.set_child(None) 55 | 56 | class GamesView(Gtk.ListView): 57 | __gtype_name__ = "GaliGamesView" 58 | 59 | def __init__(self): 60 | super().__init__() 61 | self.set_model(singletons.library.gtk_selection_model) 62 | factory = GamesViewFactoryBuilder.build_factory() 63 | self.set_factory(factory) -------------------------------------------------------------------------------- /gali/src/ui/templates/about_window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Gali 6 | com.github.geoffreycoulaud.gali 7 | gpl-3-0 8 | https://github.com/GeoffreyCoulaud/gali 9 | https://github.com/GeoffreyCoulaud/gali/issues 10 | 11 | Geoffrey Coulaud 12 | 13 | 14 | Marie Moua 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /gali/src/ui/templates/application_window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | _Preferences 8 | app.preferences 9 | 10 | 11 | _Keyboard Shortcuts 12 | app.show-help-overlay 13 | 14 | 15 | _About Gali 16 | app.about 17 | 18 |
19 |
20 | 21 | 117 |
-------------------------------------------------------------------------------- /gali/src/ui/templates/game_details.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /gali/src/ui/templates/game_life_cycle_controls.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Stop game 8 | app.stop-game-requested 9 | 10 | 11 | Kill game 12 | app.kill-game-requested 13 | 14 | 15 | 16 | 54 | -------------------------------------------------------------------------------- /gali/src/ui/templates/kill_game_confirm_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Kill game? 6 | Unsaved game progression and settings will be permanently lost 7 | cancel 8 | cancel 9 | 10 | _Cancel 11 | _Kill 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /gali/src/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoffreyCoulaud/gali/c73fbfb7f8cbad8a646d77020a2b7a7bac7169f6/gali/src/utils/__init__.py -------------------------------------------------------------------------------- /gali/src/utils/cfg_parser.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Any 2 | 3 | 4 | class CfgParser(): 5 | """Parser for .cfg format 6 | 7 | This is different from ConfigParser : 8 | - there are no sections 9 | - values are strings, quoted or not by double quotes""" 10 | 11 | keyvals: dict = dict() 12 | 13 | def read(self, filenames: Union[str, list[str]]): 14 | """Feed files content to the parser""" 15 | 16 | if isinstance(filenames, str): 17 | filenames = [filenames] 18 | for path in filenames: 19 | file = open(path, "r", encoding="utf-8-sig") 20 | lines = file.readlines() 21 | file.close() 22 | for line in lines: 23 | 24 | # Get key and value 25 | parts = line.split("=") 26 | parts = list(map(lambda s: s.strip(), parts)) 27 | key = parts[0] 28 | value = "=".join(parts[1:]) 29 | 30 | # If key is quoted, remove quotes 31 | # ? If there's a native parser, use it. 32 | if value.startswith("\"") and value.endswith("\""): 33 | value = value[1:-1] 34 | 35 | self.keyvals[key] = value 36 | 37 | def get(self, key: str, default: Any = None) -> Any: 38 | """Get a key's content from the cfg data""" 39 | return self.keyvals.get(key, default) 40 | -------------------------------------------------------------------------------- /gali/src/utils/deep_find_files.py: -------------------------------------------------------------------------------- 1 | from os import scandir, DirEntry 2 | from pathlib import PurePath 3 | from typing import Any 4 | 5 | 6 | class FIFO(): 7 | """A class representing a FIFO queue""" 8 | 9 | __items: list = [] 10 | 11 | def is_empty(self) -> bool: 12 | """Check if the queue is empty""" 13 | return len(self.__items) == 0 14 | 15 | def add(self, item: Any) -> None: 16 | """Add element at the end of the queue""" 17 | self.__items.append(item) 18 | 19 | def top(self) -> Any: 20 | """Get the first element of the queue""" 21 | return self.__items[0] 22 | 23 | def pop(self) -> Any: 24 | """Remove and get the first element of the queue""" 25 | return self.__items.pop(0) 26 | 27 | 28 | class DeepDirEntry(): 29 | """A wrapper for os.DirEntry that contains depth information""" 30 | 31 | dirent: DirEntry 32 | depth: int = 0 33 | 34 | def __init__(self, dirent, depth) -> None: 35 | self.dirent = dirent 36 | self.depth = depth 37 | 38 | 39 | def deep_find_files(root, max_depth, extensions) -> list[str]: 40 | """Recursively find files of an extension inside a root directory""" 41 | 42 | paths: list[str] = [] 43 | fifo = FIFO() 44 | 45 | # Read root dir 46 | for dirent in scandir(root): 47 | deep_dirent = DeepDirEntry(dirent, 0) 48 | fifo.add(deep_dirent) 49 | 50 | # Read the queue 51 | while not fifo.is_empty(): 52 | top: DeepDirEntry = fifo.pop() 53 | 54 | # Entry is file 55 | if top.dirent.is_file(): 56 | ext = PurePath(top.dirent.name).suffix 57 | if ext not in extensions: 58 | continue 59 | paths.append(top.dirent.path) 60 | 61 | # Entry is dir 62 | if top.dirent.is_dir(): 63 | if top.depth >= max_depth: 64 | continue 65 | for dirent in scandir(top.dirent.path): 66 | fifo.add(DeepDirEntry(dirent, top.depth + 1)) 67 | 68 | return paths 69 | -------------------------------------------------------------------------------- /gali/src/utils/explicit_config_parser.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from os import PathLike 3 | from typing import Optional, Union 4 | 5 | 6 | class ExplicitConfigParser(ConfigParser): 7 | """A class representing a ConfigParser that can fail while reading""" 8 | 9 | def read_one(self, filename: Union[PathLike, str], encoding: Optional[str] = "utf-8-sig") -> None: 10 | """ 11 | Read and parse a filename or an iterable of filenames. 12 | Failing to read a file will throw an exception. 13 | A single filename may also be given. 14 | Return list of successfully read files. 15 | """ 16 | try: 17 | file = open(filename, encoding=encoding) 18 | except IOError as err: 19 | raise err 20 | else: 21 | with file: 22 | self.read_file(file) 23 | -------------------------------------------------------------------------------- /gali/src/utils/locations.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | HOME = environ.get("HOME") 4 | 5 | XDG_DATA_DIRS = environ.get("XDG_DATA_DIRS", default="") 6 | if not XDG_DATA_DIRS: XDG_DATA_DIRS = list() 7 | else: XDG_DATA_DIRS = XDG_DATA_DIRS.split(":") 8 | 9 | XDG_DATA_HOME = environ.get("XDG_DATA_HOME", default=f"{HOME}/.local/share") 10 | 11 | XDG_CONFIG_HOME = environ.get("XDG_CONFIG_HOME", default=f"{HOME}/.config") 12 | -------------------------------------------------------------------------------- /gali/src/utils/lutris_export_script.py: -------------------------------------------------------------------------------- 1 | from subprocess import run 2 | from os import stat 3 | 4 | from gali.utils.sandbox import in_flatpak_sandbox 5 | 6 | 7 | class LutrisScriptExportError(Exception): 8 | """Error raised when a Lutris script export fails""" 9 | pass 10 | 11 | 12 | def lutris_export_script(game_slug: str, script_path: str) -> None: 13 | """Export a lutris game's script to a file path""" 14 | 15 | # Build command 16 | args = [] 17 | if in_flatpak_sandbox(): args.extend(["flatpak-spawn", "--host"]) 18 | args.extend(["lutris", game_slug, "--output-script", script_path]) 19 | 20 | # Run command (can raise an error) 21 | run(args, check=True) 22 | 23 | # Check that export was successful (file isn't empty) 24 | is_script_empty = stat(script_path).st_size == 0 25 | if is_script_empty: 26 | raise LutrisScriptExportError() -------------------------------------------------------------------------------- /gali/src/utils/prepare_filename.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def prepare_filename(string: str) -> str: 5 | """Transform a string to be a suitable file name. 6 | 7 | This will replace non alphanumeric characters with "-" """ 8 | pattern = re.compile("[^a-zA-Z0-9]") 9 | return re.sub(pattern, "-", string) 10 | -------------------------------------------------------------------------------- /gali/src/utils/rpx_metadata.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import PurePath 3 | from defusedxml.ElementTree import parse as xml_parse 4 | from xml.etree.ElementTree import Element, ElementTree 5 | from typing import Union 6 | 7 | 8 | def xml_prefix_dict(root: Element, prefix: str): 9 | """From an XML root node, get text of inner nodes matching a prefix. 10 | The resulting dict associates unprefixed tag name to text.""" 11 | _dict = dict() 12 | for element in root.iter(): 13 | if not element.tag.startswith(prefix): 14 | continue 15 | key = element.tag.removeprefix(prefix) 16 | value = element.text 17 | _dict[key] = value 18 | return _dict 19 | 20 | 21 | @dataclass 22 | class RPXMetadata: 23 | """A class representing metadata for a Wii U .rpx game""" 24 | title_id: Union[str, None] 25 | region: Union[str, None] 26 | long_name: dict[str, str] 27 | short_name: dict[str, str] 28 | publisher: dict[str, str] 29 | image_banner: str 30 | image_icon: str 31 | 32 | @staticmethod 33 | def from_rom_path(rom_path: str): 34 | """Create a RPXMetadata object from a .rpx rom path""" 35 | 36 | # Get path to meta.xml 37 | rom_pure_path = PurePath(rom_path) 38 | game_root_dir = rom_pure_path.parent.parent 39 | meta_dir = f"{game_root_dir}/meta" 40 | meta_xml_path = f"{meta_dir}/meta.xml" 41 | 42 | # Read meta.xml 43 | xml: ElementTree = xml_parse(meta_xml_path) 44 | xml_root = xml.getroot() 45 | 46 | # Build metadata 47 | metadata = RPXMetadata( 48 | title_id=xml.findtext("menu/title_id", default=None), 49 | region=xml.findtext("menu/region", default=None), 50 | long_name=xml_prefix_dict(xml_root, "longname_"), 51 | short_name=xml_prefix_dict(xml_root, "shortname_"), 52 | publisher=xml_prefix_dict(xml_root, "publisher_"), 53 | image_banner=f"{meta_dir}/bootTvTex.tga", 54 | image_icon=f"{meta_dir}/iconTex.tga" 55 | ) 56 | return metadata 57 | -------------------------------------------------------------------------------- /gali/src/utils/sandbox.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import os 3 | 4 | 5 | def get_flatpak_id() -> Union[str, None]: 6 | """Get the Flatpak sandbox app id, None if not inside one.""" 7 | return os.environ.get("FLATPAK_ID", default=None) 8 | 9 | 10 | def in_flatpak_sandbox() -> bool: 11 | """Detect if the app is running inside a Flatpak sandbox. 12 | 13 | This relies on the environment variable FLATPAK_ID to be set. 14 | 15 | https://docs.flatpak.org/en/latest/\ 16 | flatpak-command-reference.html#flatpak-run""" 17 | return get_flatpak_id() is not None 18 | -------------------------------------------------------------------------------- /gali/src/utils/wine_path.py: -------------------------------------------------------------------------------- 1 | from pathlib import PureWindowsPath, PurePosixPath 2 | 3 | 4 | class NonAbsoluteError(Exception): 5 | """Error raised when trying to convert a relative path 6 | from wine to linux or the opposite""" 7 | 8 | 9 | def wine_to_posix(wine_prefix_path: str, path: str): 10 | """Convert an absolute path from inside a wine prefix 11 | to an absolute posix path.""" 12 | 13 | win_path = PureWindowsPath(path) 14 | if not win_path.is_absolute(): 15 | raise NonAbsoluteError() 16 | win_drive = win_path.drive.lower() 17 | win_parts = win_path.parts[1:] 18 | posix_path = PurePosixPath( 19 | wine_prefix_path, 20 | "dosdevices", 21 | win_drive, 22 | *win_parts 23 | ) 24 | return str(posix_path) 25 | 26 | 27 | def posix_to_wine(path: str): 28 | """Convert an absolute posix path to a wine path""" 29 | 30 | posix_path = PurePosixPath(path) 31 | if not posix_path.is_absolute(): 32 | raise NonAbsoluteError() 33 | posix_parts = posix_path.parts 34 | win_path = PureWindowsPath("z:\\", *posix_parts[1:]) 35 | return str(win_path) 36 | --------------------------------------------------------------------------------