From a01d547b1f6c5c9b7e7457c1be74cd162680e4c6 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Wed, 1 Jan 2020 11:22:34 -0800 Subject: [PATCH 01/37] Initial Commit Beginning of RetroGOG code --- README.md | 51 +- .../corrections.py | 20 +- .../galaxy}/__init__.py | 0 .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 311 bytes .../galaxy/__pycache__/reader.cpython-37.pyc | Bin 0 -> 1057 bytes .../__pycache__/task_manager.cpython-37.pyc | Bin 0 -> 2006 bytes .../api/__pycache__/consts.cpython-37.pyc | Bin 0 -> 4042 bytes .../api/__pycache__/errors.cpython-37.pyc | Bin 0 -> 6360 bytes .../api/__pycache__/jsonrpc.cpython-37.pyc | Bin 0 -> 11968 bytes .../api/__pycache__/plugin.cpython-37.pyc | Bin 0 -> 32048 bytes .../api/__pycache__/types.cpython-37.pyc | Bin 0 -> 6289 bytes .../galaxy}/api/consts.py | 0 .../galaxy}/api/errors.py | 0 .../galaxy}/api/jsonrpc.py | 0 .../galaxy}/api/plugin.py | 0 .../galaxy}/api/types.py | 0 .../galaxy}/http.py | 0 .../galaxy}/proc_tools.py | 0 .../galaxy}/reader.py | 0 .../galaxy}/registry_monitor.py | 0 .../galaxy}/task_manager.py | 0 .../galaxy}/tools.py | 0 .../galaxy}/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 75 +- .../user_config.py | 12 + .../version.py | 1 + .../corrections.py | 4 + .../galaxy/__init__.py | 1 + .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 311 bytes .../galaxy/__pycache__/reader.cpython-37.pyc | Bin 0 -> 1057 bytes .../__pycache__/task_manager.cpython-37.pyc | Bin 0 -> 2006 bytes .../api/__pycache__/consts.cpython-37.pyc | Bin 0 -> 4042 bytes .../api/__pycache__/errors.cpython-37.pyc | Bin 0 -> 6360 bytes .../api/__pycache__/jsonrpc.cpython-37.pyc | Bin 0 -> 11968 bytes .../api/__pycache__/plugin.cpython-37.pyc | Bin 0 -> 32048 bytes .../api/__pycache__/types.cpython-37.pyc | Bin 0 -> 6289 bytes .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 141 +++ .../user_config.py | 12 + .../version.py | 1 + .../corrections.py | 4 + .../galaxy/__init__.py | 1 + .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 311 bytes .../galaxy/__pycache__/reader.cpython-37.pyc | Bin 0 -> 1057 bytes .../__pycache__/task_manager.cpython-37.pyc | Bin 0 -> 2006 bytes .../api/__pycache__/consts.cpython-37.pyc | Bin 0 -> 4042 bytes .../api/__pycache__/errors.cpython-37.pyc | Bin 0 -> 6360 bytes .../api/__pycache__/jsonrpc.cpython-37.pyc | Bin 0 -> 11968 bytes .../api/__pycache__/plugin.cpython-37.pyc | Bin 0 -> 32048 bytes .../api/__pycache__/types.cpython-37.pyc | Bin 0 -> 6289 bytes .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 141 +++ .../user_config.py | 12 + .../version.py | 1 + user_config.py | 14 - 80 files changed, 4268 insertions(+), 95 deletions(-) rename corrections.py => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/corrections.py (70%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/__init__.py (100%) create mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/__init__.cpython-37.pyc create mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/reader.cpython-37.pyc create mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/task_manager.cpython-37.pyc create mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/consts.cpython-37.pyc create mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/errors.cpython-37.pyc create mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/jsonrpc.cpython-37.pyc create mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/plugin.cpython-37.pyc create mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/types.cpython-37.pyc rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/api/consts.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/api/errors.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/api/jsonrpc.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/api/plugin.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/api/types.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/http.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/proc_tools.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/reader.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/registry_monitor.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/task_manager.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/tools.py (100%) rename {galaxy => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy}/unittest/mock.py (100%) rename manifest.json => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json (100%) rename plugin.py => n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py (75%) create mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/user_config.py create mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__init__.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/__init__.cpython-37.pyc create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/reader.cpython-37.pyc create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/task_manager.cpython-37.pyc create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/consts.cpython-37.pyc create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/errors.cpython-37.pyc create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/jsonrpc.cpython-37.pyc create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/plugin.cpython-37.pyc create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/types.cpython-37.pyc create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/consts.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/errors.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/jsonrpc.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/plugin.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/types.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/http.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/proc_tools.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/reader.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/registry_monitor.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/task_manager.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/tools.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/mock.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/user_config.py create mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/corrections.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__init__.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/__init__.cpython-37.pyc create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/reader.cpython-37.pyc create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/task_manager.cpython-37.pyc create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/consts.cpython-37.pyc create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/errors.cpython-37.pyc create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/jsonrpc.cpython-37.pyc create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/plugin.cpython-37.pyc create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/types.cpython-37.pyc create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/consts.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/errors.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/jsonrpc.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/plugin.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/types.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/http.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/proc_tools.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/reader.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/registry_monitor.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/task_manager.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/tools.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/mock.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/user_config.py create mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/version.py delete mode 100644 user_config.py diff --git a/README.md b/README.md index 521846c..f5137b1 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,42 @@ -# galaxy-integration-n64-RetroArch- -N64 RetroArch integration for GOG Galaxy 2.0 +# Retro GOG +RetroArch integrations for GOG Galaxy 2.0 -# [If you want the integration to work, please read the tutorial!](https://github.com/Riku55/galaxy-integration-n64-RetroArch-#tutorial) +## About +The goal of this project is to integrate all retro platforms that are supported by both [GOG Galaxy 2.0](https://www.gog.com/galaxy) and [RetroArch](https://retroarch.com/) so that games can easily be launched from GOG with minimal user configuration. GOG Galaxy will track user's play time as well as achievement status from [RetroAchievements.org](https://retroachievements.org). These integrations are designed for and have only been tested with Windows. -This integration is still work in progress, but it's core features are working and the worst bugs are removed, so try it out if you'd like to. -Thanks to AHCoder for using his PS2 integration as base for this one. Also thanks to several people on the GOG Discord for helping me out with some problems. +This project is currently a work in progress. Bugs may be present. Created with the Galaxy API: https://github.com/gogcom/galaxy-integrations-python-api +Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-integration-n64-RetroArch- #### Working Features: -- Add N64 Roms into the galaxy 2.0 library -- Launch N64 Roms with RetroArch via Galaxy 2.0 +- Add and launch retro games to your GOG Galaxy 2.0 library - Import RetroArch Playtime -- Use Mupen64Plus_Next or ParaLLEl core, change it in user_config.py #### Future Features: -- MacOS Support (help from MacOS Users would be appreciated) -- Add GUI (cef) for better usability -- Add retroachievements via API -- More launch options -- Support for various emulators (might be another repository then) -- Add more platforms supported by RetroArch (if Galaxy should support more than one platform per integration in the future. Currently working on PSX support in other repository, not quite usable right now though) +- Add configuration GUI for better usability +- Add achievements to GOG via web API +## Currently Supported Platforms +- Nintendo Entertainment System +- Super Nintendo Entertainment System +- Nintendo 64 (Riku55) + +![screenshot](https://imgur.com/HxauLPS.png "Screenshot") + ## Tutorial -### Setting up [RetroArch](https://retroarch.com/?page=platforms) (version 1.7.6 or higher required) - You need to repeat step 3 after adding new Roms to the path. +#### Setting up [RetroArch](https://retroarch.com/?page=platforms) 1. Open RetroArch. -2. Navigate to the left until you're at *Main Menu*, click on *Load Core* -> *Download a Core* and select *Nintendo - Nintendo 64 (Mupen64Plus-Next)*. +2. Navigate to the left until you're at *Main Menu*, click on *Load Core* -> *Download a Core* and download the core of your choice for the platform you want to integrate. 3. Navigate to the right until you're at *Import Content*, click on *Scan Directory*, navigate to the folder where your Roms are and click on *Scan this Directory*. 4. Navigate to *Settings*, click on *Saving*, go to the last option there *Save runtime log (aggregate)* and turn it on. -### Setting up the Integration: -1. Download the integration (use clone or download or download the *Source Code.zip* file in releases or just [click here](https://github.com/Riku55/galaxy-integration-n64-RetroArch-/archive/0.2.zip)). +#### Setting up the Integration (for now, will be automated in the future): +1. Download the integration (use clone or download). 2. Extract the ZIP file. -3. Put it into your Galaxy plugin folder (standard is: *C:\Users\USERNAME\AppData\Local\GOG.com\Galaxy\plugins\installed*) -4. Open the file *user_config.py* with an editor. -5. Add your emulator and roms path as described in the file. -6. (Re)start Galaxy 2.0 and connect the integration. -_______________________________________________________________________________________________________________________________________ - -## If the integration doesn't work and you made sure that you set up everything correctly, here are some things that might fix it: -1. In RetroArch, go to Settings -> Playlist and make sure that *Save Playlist using old format* is NOT active. -2. More to be added +3. Copy the folders to your Galaxy plugin folder (standard is: *C:\Users\USERNAME\AppData\Local\GOG.com\Galaxy\plugins\installed*) +4. For each integration, open the file *user_config.py* with an editor. +5. Add your emulator and roms path, along with your preferred core as described in the file. +6. (Re)start Galaxy 2.0 and connect the integration. \ No newline at end of file diff --git a/corrections.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/corrections.py similarity index 70% rename from corrections.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/corrections.py index e1c5093..7a2d283 100644 --- a/corrections.py +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/corrections.py @@ -1,12 +1,8 @@ -correction_list = {} - -correction_list["Legend of Zelda, The - Majora's Mask"] = "The Legend of Zelda - Majora's Mask" -correction_list["Legend of Zelda, The - Ocarina of Time"] = "The Legend of Zelda - Ocarina of Time" -correction_list["Doubutsu no Mori"] = "Animal Forest" -correction_list["Bomberman 64 - The Second Attack!"] = "Bomberman 64: The Second Attack" -correction_list["Tarzan"] = "Disney's Tarzan" -correction_list["RR64 - Ridge Racer 64"] = "Ridge Racer 64" - -correction_list2 = {} - -correction_list2["Pokemon Stadium (Japan)"] = "Pocket Monsters Stadium" +correction_list = {} + +correction_list["Legend of Zelda, The - Majora's Mask"] = "The Legend of Zelda - Majora's Mask" +correction_list["Legend of Zelda, The - Ocarina of Time"] = "The Legend of Zelda - Ocarina of Time" +correction_list["Doubutsu no Mori"] = "Animal Forest" +correction_list["Bomberman 64 - The Second Attack!"] = "Bomberman 64: The Second Attack" +correction_list["Tarzan"] = "Disney's Tarzan" +correction_list["RR64 - Ridge Racer 64"] = "Ridge Racer 64" \ No newline at end of file diff --git a/galaxy/__init__.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__init__.py similarity index 100% rename from galaxy/__init__.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__init__.py diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/__init__.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..27f75f5cb28b13c24e165b1a6dd43333a61ee61e GIT binary patch literal 311 zcmXv|!Ait15KYr{T~-(W!22FrM8tzE;;PWgqM+=h1c%TJZ8S|nl9k=FKcYwf#V_d9 zzwl&Qbl|<2;XP*F^=vjJvC^-@ry}<6e)+Fa9Ihh#aildtBFJ0vo*>2agO(IWxF?bY z5%iJF({kUw)SDnxHvzCbGyr$=aW@5!tu-zHln1yCsKubVB;M?;nzsnhr9Qas2!LyC z0uRz?5AaFcINApKdA+${Ymd&eXW#IOEv>!ffwPsVxMIb9kyoZ=1y_8#V^(cysXdDb zTqzW+9)wKlfVCSQWcvIpd%)n#(p62Co#dx~E*3*;86)R**G(3!5gUbZxKu;0i!1fGQ92?CO$YU5rf@X=}Bby98Xy3T}1MHyo+0J-c1VY4V?1ZQR2a7;xjRy2=<6m_5tI2Uv^&wCsHVLK$17 zYm|eUD{u;E13W8q9qj*TyQtr5)o~=L9BCnnB_T$cnk0B`2yrrz$vjdQLS>N<1|OWsT4`$w|E}K=qZ?od@0=2VvXT$pg DW0n4A literal 0 HcmV?d00001 diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/task_manager.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/task_manager.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..505cb45bfa30485f89a100cf93d404eb9546cc93 GIT binary patch literal 2006 zcmZ`)Pj4GV6rY*>W5;$v8v;cwNXsFB1-76W?KCQt-_r)-d$qe3a=J-_EX!HQO*F2(Hs%oO1S(bR1r{v1!NUKCi%x z$E$n+dY{)|orbCJL~8%5D2sYR#%vlBdu$uf)NL>YEl5Z-JtojI?bzPs1VYg5rf0k` ziv~jCH^vV&gj5o7nMFGYEovc~%(zOVyT(QL--tve|DJ5Db-z(Us_vfZM{)PTaJU)i zsQW07qqMvAc&i=fgYH(8M&rZoFdg-hOm(5@C`|?L_UwyRl4;SCkxufgmEHTe^+afy zKag?1)%vh~ck0zeXyKqtw>>;G)i6x5M28_%k|-Yxp+2qB$;uor?U~a~?4k!;=#Ji) zr1}!f2|1xB?8Mo1E%ntSSCdm`j~>$_PqQ`&*_n0`0%b$ zc20 zy2g!jnVUKnDwc^wVs7@MjHe>J_*}^n(tk#nWC-ac`;n?fHn;@ri<)X==thkmF`ztR-#&xrk;74KgGVmZ{GP zwIrcZ=JFc4ypHA;nm5qA3dXny#HQMG0umtcC~81cXo$1CjCn6X_LE!xlj$Yt z<7Tx-S^y8@+XdMJ!Jm^&@?GPI7W9mrgS?+NP?Y7t=2-B?|1nBOLK(l5SCf^=+<_=% zb?_LoC6aH!)4R}=Jt&|eljTdutZFWSLdW0ni@RXPAIv}@Ahm)(G~06u*q59o^k-V` zRe;H+Fr964Qwlwj*_?8AfJGFad=rhWiQkKKACF)Q-grp3x&q(02T`KmM>O^y#~5(y z!acb$p9MO>ziFpg!SfT3@-Q^DFdXE31Og6CBMhI9qO^P?u~@crmL2&v4lQH<8e3gt z0zoS*c$HSY+MTK^akg2pIasZMxN`-{!dh literal 0 HcmV?d00001 diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/consts.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/consts.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..813fb1cc53b31e3d7829ef26e36f7da7a3b9da65 GIT binary patch literal 4042 zcmdT{$$A^d5eA8UAqi2`zK!ISwn$5$EZedqiz`HlB4~jW=`z1Sjj0AO)?fzd?jZms zx8#}!$t@3(CzxwaeubP;H3Lv|K3b<-0(?``Yjt(i|5x{{OidLM^h^G;^oQDXBJnS- zjDIOAJflbNlE4H`xQXrrOmrtglMSqIw3iRL@5B zG}UvMr+Qva()bJ(>WTT{ucSFKKN+Uhy`USWHeAuRxoP~Hpu#hHv`ZzKfC)^%Bu+pQ zCn1GNNMnjrr6G$M$YB=pn1cf5p@;=2VG*XV1k*SLGdK-poPl##hFLrZ=W!P1@H||= zIk<=y;1XVh%XkT{;AOaqSKu06h3j|?Zs2vei8tUD-UJJ8!ELnQ4&H{3@D6;8AHgU1 zF?@=jz-Ra=e2$;NJbn&e;5>YZU%*}b5*F|-EaC!ug^O?xzk>UC4=Q*cmaqa3a0$M~ z2k;QThHvm8e2d?}cla$l!tdaFd<2j2dw7D6;VC|WAMhzW!yjN7pTP<)!z!-88m_`R zuE7SbLlrmRIaXm4pTi5>gqQdNw(upq!Y$awS5U)k*ufh7h&%8ae}p=|hFz?~9`3>$ z+=G350|&SdKj8rY{zRdHFt_6h)ACjK_-Z}q^?YR{T5ZvFytdUFb?Go0c<(&F??JNd zw;`>K6kSO6v$`UA*xf^O55kbftXQ4Y2xq#R>+AnWOVR7iV$#|a?CFGzcK(4-;C@eSnrLdEt% z&ZB~C`a?(yBNPp!eY6Fnv?n!Wn`AVS`AMuGVad_9uaJP{8`%vY&vQF|pe1dx+lEYE zM)pr0lYK{yc6{1B>q+V^3H#v#a)2$#8L}-%sV9BNaCxZ`ziou%5^|$3fuhMhJz!Cx zu{1gJi)aTz5zuYOL{6kNMOPftI+JZuC`n8kaf%jEYXDTF*mr!V-yMPneG1k^_eVm@#p<@`N90E73AgV)M1s z>_80}F9?og7qYEEyhh&f&>^q6BHmTY;e~xWa04BtXmzta|8tmYG(1w%XoQ7Eqw8bf za=FxK{4o&js3+TKpl?%ic5e*UDucBigLQ|&da&XTY7EwD3|7e0Mqu5G!MgGutm_e2 zSH@s1?ZjZ+amBHK((?$hD}jfM+m|P}&1@2GH+`7ic040J^y{%lO5^&zYCS-F>Lw^4R-ZQ7ituV@$W0WsPD7T2eQjN!~)9T6y$LmVZ)Q7}aa{9`M z0i`wavTZ7B$~huutc|I#=9_h<0x$hMqQZ1lN$I^39yL))fXu!m2MnPAgL;otsGtj_ zRo|7i|0d?hwGUZPu2ZOA39?3wY$~q$T?#0{jX06pvDZDy9TJK>kb|Th0?oGct3M_ZRY+c4lsT#qQ_IU_>y~UYKWZ%sH>9`)N-Ip-We@9q;8IKi$3^PZP&g@8Ah$)D zL|ay1dc*OAONJPshxhOQSDk#j%LHfjrG4U@-`DksNWFL7muazm}P9_NPLQEqtkJ~y!c>L{;&R#^=IG5p3`>zH}^5RYn%(S8vg z(|JHxvRZMAwoFGD%NCy1l$O)&xsvl1T}7HhiyBV_WwBtNTE@3@ ze1EYhoR+U>+R)OJqnwswzh9@N{4OmfE!WW|)}>0c&2VnB%eSA+F&-G2IxNSnwZ11Y zZYgYuz_UAC35%oAQDth+8-XdR&c-vB?T#bq#G~Ela5`?|{#}RWr&ej#q$OH8f^JxN zLzk%5FxD7%Y$%5g5*=QwcLGDi^@t16m2K6f4inDB7X7w$dwy2sXkRMNg0KIo$b$E( z&as$fah}B-iwi6+vbe1?7PlN*dRp!dd-8bW1Ws-8EJZfzvJCa2({(!MT)7hv<%O1EVS`SNbnB ze8#TQ{BVHYCl1B!XZwd^@caHD-(KhHa!V$3UUr3J#2r0-qD%c&#_J|ZIMNAZn@ouxI*J&v!qPubi^bvar7xja|S{RiaZ BlY{^O literal 0 HcmV?d00001 diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/errors.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/errors.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..643a68fec78bfa137223b7c36f28285a8a236c4d GIT binary patch literal 6360 zcmb`L%Wm676oyGjj&G7JS&?sXV&>YqjfFO712jm3+PN4m95+ZDpfH3I6V6y>LXpal ziesUxra-oRgm&3C>7q}dZCBZM)m8s9;y7_)pdhCVG~sUv|D5x2ICEy!E0qZgSMksC z4_mnVi-f*PxcLU3_@-@H+~Rg%b?jZcQ`jxomMFyYN%1Fp!{P;A{Mq6~VZSQwmY_>~ z1bRg25$IEV6na$YQ_y349C}>oQRoRi2|cOw7<8Fepess`Lr?K(=xL=VpsTzFT~m4z zdWP4b>q?iQPxA(JL+J|iET4m(Q+f(|o-aTzC_N3m$j?BZQMwAf#Lq&XRk{X!jxR$m zD?J14@bl2;m99fy;1{7UDt#LI62A<6S?LD9^2l2G=nXMlDGaJ>-EQD}Eb*i8u9T4+ zlplxB!suYAJo=fw2tQr%IW)rBwYjxh;HB5ro~@9N?-pNMC2O~Y4n_vEYrSM&gh^`t zP(1JXQt)lBpiC@_{vW}oh);YPO=7*WeziHwezbop+IYe+DsI2Q!?yZ!$L$=M`XPsr zxYreOFy^{`=qIj=Kb2w;>rXM-oDX#919t@p2qvkYXQI= z{H(PVc`Rsce7Di`qE2gr1?=Tvs~hy%ei*m#NmvjF-fF8aR{by$ZK<%WhS$GXeJBzc ztx0cxb@j96we(jl*7aM0sK(9ip&UgXrzlxP!0s;}Pe~JM9_3+ggCo)kSx30;V8V4f z5$^?bUv}N+Jr)eVQO<)(nx!X#z;)#$dQ#1bE$Nk#Woqad5_A>An60$IL$0qg@0kes zc9g8WV1B@!1R}>1CLsMr>g9w{;xO&DQjo+SJ0j{OIk?fQ)30f634FK%r)M$n48W(8;T;()2HBIG!MMJ+|(XiBOnGMv8&c(8i2CE9j3{wNPG?FwX(gv7j z)A>71^r48mQ5ffVE9EQ#=`U+wQW~y zZ2)t6CyMT~@Q~(a5hrnuVMnuV{{noR4vf^gMNUm;M;;-5PLApBg3g7eyLW>z!?Xn3^V%AiNE=|PZAI<2 z;G5yyAQlJvLgtw1-s~ZUetqaf)0@y<*@o8@ofe2Keh`-#HX6E@wLh?tHo#VSAfqJm zq9E_^YNP-Dly=E8zCO{}fYz)w`XAO)2fi!X8>C1ZkgD#8PB)TF9 z{J+wD03m4ug!Qx*ypQ3=+9H00nkzd%|0>R0`m;wxYbSMcV%TNlcT496eoH3=XGV|K zkgs(Qphwz(USl)#BB~+6WQ)Z~3NZ)2Ux?qOJO19DGDC$QCr(Py@v%t{ohQSSlWa8C zb*?lTPYl+K=cn*r(|v#^X#<|qnqZXNi+VUF%6$0e|4k%osgF7u+9RiI*zW0+fNlQ0 zn9P7(h4WqQ4q&7WfK@lC)<`x()I)9TxO424{O=1Owm39$SRe)Chg8&dQ_F1VzSYSA z-NO6PnX#jaV^iCM9qH|r*}=^4jJ1i=)8^AS3T4-m$jOpLG=r(N7^k;K=hf;2Q_}f~ zr1K?7n}12WezkE`n=rM-PzAH91yyyUR&=#qsTo&I9BQ@5sSE2j0XS{N%-OaJxnUte=>u>b%7 literal 0 HcmV?d00001 diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/jsonrpc.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/jsonrpc.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c6db1f5fef7f6cc1ade07275327009437318ebd GIT binary patch literal 11968 zcmeHNOLH98b?(z*?Phd})(+zNdnI7o1<=Rr{Bqq0a< z2DrEH?Y@2Q>2uHHJE!~I$;q;TpZyP&pZ)usVf-6A=`WAQ1(fiNX&9d2nQf!aqt!M$ zR^2k$w%c~csXNj(+qrs9#^vjIITq>#9G!NtQ>vF_Os-w-Ow=b#<9)-+dxiUkSMV!a zc6}24qE|w{cN%SYBKZE|1H;w+Z^!K1Y4e;?M{?_1s>@342oJGyMQ@T|+0ckCB-{Q%CtZWJZEwA{%STa4!v+tMcw&!>`5a(#-)^iUG$EfE)OewBh3A@47wdN(Ix~kSpC*#pV zu~EV*s=nDb8b;qjZT4-{R%CA(JI*76JBX>w+FUMnnqAM23mrcUL1*lEZsf+faDB~J zak0^81+A#jm~&z$^xI3SfO%DsRS6ZIvD9cZ+in;(8h>s4dgJ2R#ZNqt}TKG&%e2FCKvc4QERf@wUs{{k)fLM#R!iztw)zm6 z!_ePzARNRwwK;1*T$}?nU8OhcNkrS-8!O`^J7n#CEFFqju^h8(m(3}ww`YWKj-nV9 zC@R#AMk6ja8lA4U-e$YfXxv(N+xm*^k=lbv)jm}5WFk6Ezuj&$1{BPhYLbJJY%Ada^e`fR? z)u0C6xy_Me+UlgYk8wB1hlto->E`IE|Ebxr1Y zYVORZ&j9f`Fha%)Fo$;JjdWr#hyCYfPT_j1<9FAeS*?t%A+d=thgZccPtBO$PU#0@ zW)c!!fF&aiVavamW zl}-tUu>af)nE_O4x0(Wav~Qw6;*ZB1ij5LdCgJeS)tov+$G>W-LlK<+mU+*NO!zFo z0RTSxk)1$1r`9r{T`iHN>h+txc3f40zG^F|Dj#s|HY7{A!Lm>H_#?NyuHEC1Z*#=A z4RA$*{A_5v*b7G`~$^AjO~|QT(S@Z+4o0A^clD0YCY$5{4zhog}C*5Q9@#<79jIa zrxpR0H?3`G%rC85Kz~C}%8sptH)C@(HgDtMC*7diY4v=s!RSX1Bg*=9`JC5kMhIHg z&i{aOQ||IcZvo?jjZQ1{8{9|DH_HmPp3aFbpa*(b5yBj`Zl9c1iggmCUOoh zOW127>+p&Mz9#Yxl1-==brzvs(%FN0S!WIE6FOT^ujnj6eNtx!>Qnx-&I@dr^%-Ua z5KQl3RshFY@0j-zj(af%8G-wdz4|_{3duSV=daNos>h=0i=_QKs62_PBZP)qY;miM zhAr{6L$Pb*q|QeL&yo>EiM8$8_1+Z2CB&;$vAGJNe4*hssX>a;iyl%4NFAVYM`4E; zfcpVWHCtM3!+5Fz2-#XnoJFW;st`*>$Tc)NI@vI%?RA__=F2D36_VKoilBom1{>E< zlAX+(G0~E zR=Zuj;aAsGw~0 A0L~+8fo=;YQHBsk%XTJ#25xjW0T`<7caFP+sjWX{_pFKN050LcEYdDBP2*buiW44!mMJ;aeub(8`)FM zS7DWZ)9~y8YLJB2urqN124Kx^MvB3NI*ST!h4E_akk5*dfMTj)U!C7xEvUL``f zh=JN~m3s#g`4}7W0#Xw0DkZ~0|NpYm3IEq*MO~m6h@5;Etwc`LJ3RZ%VzNJzf#J=% zi5pX4IW{gV!vixnslBXWe8oHasP5io229=Fkfa>(!iUIUeqbOMW`?JMAZ+F|?^r)J zf&(U6ChTx}?k8}kp8*2U(`=eiZYS@VQGwQ*sU3_f0@RtN5tVkzNcWg}X4ANZ*%Hg$ zJABn|`Y_X8^%RtPBl1HkzhDK(sxddA=o!@Sp^A&NxoxC@W2fPFmyk-0RBJ6RBt{_4 zdH#*{<=Aff0j%~-H}H@tYXxiTQCx6ohgx0S)As!}IAUt3;KwnG9ouR>h;T7vUc&&+ zwt%)B9GUnm%HIsEc(0r?d_Bn6OOol1#cpeS%UlXmCB z*XFN&axrmSVzQ!Yhqf)r8;6;N<>OzTv(@`pA$D2;EFhVaq{qgeBiF_7>PVgG?)>ztmz_mFsqQco^<)Ot|de#*8FGNUmuJn^0iYdX-FEE`6M`LJ^Xwk5TPTle&aUe~L2T!$d+2#VEtp;^C2)GB@X*{WfY&F%>mqoK zKrpd6C0s2>6I%xFNI0BR+W=^hnD_7Wyr6Lp zEmJyUs-7=rVz#d1SmqWpV0gBAD(wBvr-6T$ejldGzuWZJ_)-P2J|Y(&pkU;Ns^+1Z zn^m;?vAvA_#N`{zqc&t>#vlX4qCQ3U zFHpiNDg#2}uvircjb+ZFeZX?e8MKe1-8(uaM#Fr3Oqy&?`;Vx$Z5p6$E0dX z?EkjClS3PkfR+dD8>Q>#rj{>UFK%1?Tq0feN@>Z!`0_(U%ZYSNKnm_@DadB4j7XQG zezk3G8!MA2Q;^F%#!YioEw#Z(M$!+g2lkR9uNI4&1<*aSv!@TLQc6XloLcYLV-l69 zCWWae4GnTU%-^Ca*@t-jxsOX)H zDm#R zZ4Kbw*Oqc`ts8yV!JFickJ!bNB$N{6VJcqitNMZ5ebGha&Nrlb3_~sTGgkDms=?}I zRIv@HffOF1z7Uxlt+A#LTl+^GA{rzLC8$CaM#B5NR{XCpDC8R_TG@S~B>P}%kE8Gi z$NcJ;nk2$8NO|aqU_cmPs2{UB!Aj^9>pn0J@{phL>Q7in4x-N1F;*Gxl*=wXy80=q z#|^`nO#u8#n7qt;_>@mz0H0l5NA))-BX$BvYRpo==~%N63a`%voA}y9%kCx`&W;#zIePFk^kR%;?_L+q@!9GH#IM?pFUZ{772vWVm zv(vl7GPs86tvH1D7l2 zA@Dc@&oZn@Q_UaQ%5o7!LIz?UGVn`#69yXi5ll<~tXHE$@4$!Z(ssnKX6I9nIdiv*E+IS`MV*4eX#zw-q>{SqDPsOu<0U2n&*lqy~WSYR$EEIAjtfCtJq)XDw z_@v9ziD)OqDSL;dpxTrfk;I`P5+kLZbBq_*@z9UL)gE5Y~7i(sTOj_WPXyvgKpcaM4 z^1@n89y<-oGxF~0|4vGG#5kV-OGTIQ)fe(j&^~SLbZ%FJFVSM^bGTVX zF9Yyi0N;8ncj*L@6dQrZ+@x0FF7D8lTR_H#?23Y~vGvqAL*3%=Sylr}DY%%%^f)&m zO}h|cF!3TFW4A3C1spc+&lLX{I`E57b$CU*1O^5MCI-t>pNHt`^^`dxSjo$+C-^X1 z`!E4nHOvhYmMOw+?=RFH3_8uH5xa!cQSaa`LPhlvr!}#g$5Ig{(VprqTjHO{CbiCr zOAIk_;BIc?0-jSV72Hj}ya$e^tayIzsE+U^nq7o7g?V?v19rs?|Gw z0oMiC)F<@^vkUqypMHn9H#s-_I#6B_>WGn8MZOhO0{bNh z`bhJe&QQqS?K$J(IsMh=c|5EBP^AcQspw?1gi|y7XPlwG{ETzRoVAOUsXsQ({0|xV#@vfvX5k)I96p-schv@l}FW0RjQKxv#QFk zROOHVY`$~)aqrCB0bg?DO)XHj=ia{kIDPu`>2pq>?)&_P4Y>sVQhz$|D?eLKB>s#Z z%wHNe&*Ad_9Dzs(r;^C4UnlSIb26Xg-&8)uziIv3lkdTEvXZIx=6fB=ld5E^efhp> zE}yIR=liPz`GM+&{D$gaevsd%D;ujr`60gVscfna=ZC8!`4N83R7R_t^P8(<`LXJj z{1%Sut!%As%Wtc0&u_2Zm%p#NBfq1%GrzOCE5ECHfBt?>ldZVb-TB>4;)R6h6S?aN zk@J$5_N=7xdl1tv1`so_lFUCKHi*INiTqySoJ~w@{1B~8OiwsL`gm=zI*|!dM`}w! z`dGQt2-2s@ej~`9UTBo-wPGbmo%I?)&-ogjkfHa4S8OawZz35Covbd@W#eS+jI7T} z&-a6Y^R@X}{Yve)lyy0g3Wkm>EL6&+B9+8(!SHmwQJ&FJM=NEo)(A3ZJ$c!a6TLzI zk${tn!D3F_*6@1Dw1?c`h36leOksXfWbhXZFma4yTO$iuhWM8TEeKQH{moV zZoA$IN8XQ&6WO3&09B<*(f2(+KT!4y_Bo#^`czYpEBlPCTFH~t!l37Jv9jm|J^tc? zCxc$yLiA4B_bM}iQ&)vML0_T3MHdRH4Fta2i(nHje*i(k$tAO?K_};AoaRUj{1ZJv zwos^HtP~1Cu287f#bSl-2MUF^7K;`2h6@cg=;}&drDCciGC^zi)x8QCF7k5_H z;pY;b?Oz6W&*Aca02(#JOE}=17!BY;X^|E^`0WuH(Tm@V*Xw1yzHJGSecxd|mR@o4 zImGsR1Kz+)QokX7MsGGC^}x1-Hz+o|pA>^)=H|!0FZK!3txDP2t#163&5R8fuu6vibAF-SDlPh-PX-w=94CCxaM~<{JR+b5pf7`JuDtUn)^K$ zH9U&v$He1!-VK;W#1nXaQapv{J@)-4#nX6x09e?I6jSe~#HYkFh}nmjthX0<84-sO z`>gmhVjo28e#AV7m?PqO#Ej#KaC%f6!_$QK5T1{to)h8)Jb%JFfae$Sd{TS{&j%Tf zm+*W_yo~2b?-1TkCe`-cJip%%HSqk;c{ZG%My1^Jz^tBchB_FNt}?J*VQlT^v^tRm2@ZO}h|J zSg!%Yb+Lfh=fzt>f(nk}%|p6eUo`OMnC;a?aT(9Y?ei6J70)N^^O9)d`32qTFNkG4 zy{MnAiMR1|5~Ux|rGHVpgP6}K2$71Q{*w4I;$Bj58tN5s9dV}^>O&0iSHxdK%*(pu z8)6kt(_#d3!`Fk%8D{IvQoaa+uI#(akK9JxElJ1% zNbJ_UEAB$2*qEuysvDUFCQHN#=&x5_NAc@F4;D!CrNE4dIWl=OjuCJHIcR*6Eo z(X)b?tdJ4()VrKoPABO(i>q(B2Vt(!zmm9?X$&kUE+O_(-*RRph4>APLH$HIHwuay z;&4-An9Ce#j0$iN#Bau358sU;&lZtExE1`V_g#X9VyNV8h)IgRchfp9hq&z=2VV4U zkB;4d*!$X25+pms##u)U*%CJ)Zl@R)BeTg!>?mS)5uZ}&*6we(mv)OSv(C*VmA?kQ zg%o@A6TN*vY%^~G=XSi^EADH_zfbHiDb^koJCS04B>%YB8F{-4Zzm#e9}>GFZ|}$3 zPek4x5cfykx_Emq@^(_Vk+-|?_E6;Q!(z923%K{-?IYrW7Puc3drb6+kdG$zPd7htlt{2#n+5riP~w6DLrUD|A8+6ah@ z91&#wxy6Qn2usp~NtPd)^%{jzaiMszTq!roo$&u@dACjnn5ocje;X0VOYF4iEJ&7w0p#c@cws-@jve6Sy1ik17bi{0srlRB?rV9&JK3mWv7^HkCQhkLPr9k;XFYraQ zfE!RTw2ORi;fgGS5`&?_99S?!@p;rKIKu1sW>BKxCA7@-YA-pCX7h7KGRENG(w4m3JE#&; zjifnG^vZpdKWm0d&}+ywqlhxdD4NV~Y$M5_*HcPyyK)6(gZWv(Mag~*LBbh=a*|1A zaSh_iIK$2$Lc}CJeS@O7$<|@smZ(7bqhCtJ zwQR=?=J3Q#X!iJZZYEB`R`epCcn))>VMysIRqDPM^j|4r7T|j^k=FgIO1I^dY&3P_Qn=T~GdabhL z7B3gemEy$;1k4$rAF`+CP7(K*dh_kckQT{wP{Gh2pLmn9!ax9v#AMFZe}H=$6?FlB zsGYri!8Rk^+@dkqo(4VEt7T#-k-o(!ltKhIkLH1^JC>bei8n^c979dLK`!dC5R9)?u?GCBFf2neKQ=Nqu}t-{8rM=XOtMx>&g^@Gg(75bISNKTzkW2OSR;ToPpVC7Xj z{u-{Zd-mTFL`9P4EO^B7==$4K4oSwVjz7GbS_7D?$(zY*$y$09CQ%v%1his-*HWwL z<&C(<3ybkAgEQI`zB3^dlJjtK9>5FJ38e|uaH~5L@z8aYvOk1iIpvQor*S3L5@g>c1e=kH=k#(9 zjQ}&hq$IHi)zrDnYqsTf7mebtXK{G8iIE)#~a)?$8 zN&94fRfBaVrR?w5Qp+jkjh4V@NcP}j0waX3CV9l`QLqYI!p)Q-D`ZSgQ|F#RGS-Bk zp0PNJI7>?4TJPY!>M-srZX^{Q^b#F{>;DiBX1+gY+80Notz#EC6=A43JW|hfs*%m9 z9knx&P)GeXRZ94Q(3EWMSg+a+ru6~kC3Mncqh$?;Wer+7NH({s()RmqwFqUoRG0Nd zXwTjxq!1>MASD-}`YLj0>-Ddo+~1)Js4o)9W=?~K94&wl>`bc1a6^FjIU>adCK7ZK zCKAM@*3wEKy416l(RaOTFu1Js-Nd{|DnFZT6cMMVn}bIoB87UsJczPSOyuMV{K_K~ zJWm0QXIY_uJ3mt@!s1?$rzvKI0?OB=eU$C@aPg{30`h8_WK;YPJIw*zRu;}$T1&`a zw42sOMLe`L+4jsWHzFY3bNkJ+8M3_!{4@!K3V3c<6?MtaP(ZMDX$B?wV_aGu7y_po z3gv-_h-n973aM?U4dQ`1jaIWD1CWT~G^bxx1HvtWU|JcaLYNEC)ooFQIbMjFK&kmO8*3ybXKcA>2UrRL5 zH=sp5TRDlhl91U*0!U3aA39(2t}Z~|^n?q+++&DcSuFdAz|dMy<|r&)&VT5je&|_5 zszI7!g1+M>3)61UXNw4WHEyvqRPksLGj1$mnT9PurFso3Y?VJj$$DPNLW>%3pcgsK zQBAWgNn0>P9nTn|2}>Dd)%aXX>M_|#tr)j0)S8@bZXlhNjc{x@%S6Vb9g30!Kqlq* z7Rgue{@>xUVr-V$V3im{IxX;9q9uWd`WF{WyO+Rc-lzE^IX#ieZ!nrI8@FTwNrN7? zsets3;)$c=i@56o8m+JXCtPZ}gY1xY3@4rJFkPUGhmqFm1VpSviBywMm9U{Z5le>; z$I~HN1WE+m_FCSksqpdhH6;*5iBK#`8g*}Wsz`6Tu)r)x5;SnVWf_>`RF_n z{TVL94-AiHI}yNq0`FS&GwKTxBDfA0H`EuGu)_WfQx50`!W5|#WQZ47q@TzXKS;q* zf-G!Htbi&bBQ1Wgb=HkoqsYn}F}sE;=+V%C3kK@BG<3lwkepOn@R3He7*c~PmC2md zV5itr94&e(`hY@;4XnwK@zcFhu2d9cpQ6H{M58`sM|RoL3cVWj7TrbI5Z#4RSj+LN z#VXWEdI10sfQ56$sDXJZOg}jZ@j-Y8FQVA_sVR4135!HE3$%;BsR~4kTHTN2qG)Dt zE>^r55uH}-aasXCfXcmv1JQ=!L0Pvardq0@GRpuKxh7}pjRO2fB!Iyf7>{(vf$o{9 z0K!Ls?Dg(pw@8zuDi5glVa^XbFt*4JaJ-7|bplECP;YD7KWb7i!}uAAzP%c3S-M#r zh3WX)FHM<-+-U%K7N{TJbH%#|OS4a_CwmS=0k<7-n87xP?Z5)Ju7ifgEvjGNA3^N? zHzqm?&>*$?0Y&puQ76UzHx7jT_xgT{Bwdh7v_&#E2sU*LRNPt%kzLK$;vx6tE&K z27?Wyi%)`g#j+t7U|cGkSQ4=lGT71B<@FnW<69)~0xpL4qLg43WMT_foWPI>ah-gJ z3ZHVxUQAU`b?ek*tBPYM$jgfk5q|Jda1kuEmF5drdBG^U!WH<;LSoniwZ(58;k5J+ zS?e0_q(WL!WDG;B;{xJGHdqj+0_l(d$iyEhSgoGKXQja$pMH|`(3%*hk~<-zeDue8 z4R&=}+Y>=9gd#r_-WQO;6QuaDM)7N)KrM^Q@8CYjs1?e{{L8v=*3^S3QWO6O?2X3c zZr+l;iGVJY#l4U{iBYj+FTE=K6BNd0WWD-}qkXhsic2qJ$|es8T`NX`24da%Z9l|~ z2vxu@c2KA~^t$gaY+%HbZ#=oTjg)o9lqK$vY<)G5CRMX;XJAB-%*iJ;T}EezUkAnK zhr?Q6izE7qA0Cug{tXIR=LA|sr(E2h+ggT)(N!Y}tcGw$6G8J^vBP0;0kR7iG>8++ zn~gQ@sO({DQO|Z>>8;@7^Y=AqI8>xt$Op2}# z^`Bgs8O!nWW;fnJQ_7XOa%qm{Qsq6-R!68kXz@Q%|5OVwwuCSXcskSQ`)GE4G?LSL zYP7O_ZKt$(V*6)U!5MWd_#|}`+kks%)wa1!TO2x5n*JJy0OQ|nhbiS(H!{CD-ur}m zB?_$da_|>RC5W{x6JHbq-vicSF|eCybG+mb!z?G-cH@#^fYJ2a10k_4 z*FlV{DVY|OEj5#6HMy*)xdl0L4axJ}Q89xRR`TV?iqR#%UW-TqQG`$-cZv{Q@=H;K z3?!SkMu^V1zMY3$2StKMFSR#0o!=3M+!?qb_;a3U>FRic+RoT+H3hsv%^>SR!fEa` zeY$>)K`!)cx)%xPIv83!Yl>(2Tx%DW6hfpVnp0-e1hXM7FVvViZU!RC7Rew}N0IUQOU4qX7FB;@62eO_s$QP>lFi71RNlLYy)E zvvqp>i3!(zfIWUCHZ-*M5?OeNG`h*0#%9yjhiWweo*dKl8mv%KX*Y3+CIwP->oeV8 z)(uVHsG?!u>0G{GASc^u8IK@S{s5ytzpbM?kfm6g?II~so>(%3~9zGMEO{B&0hVmIczax9K#y~^l@J1kc8avd1(GxG%!)uzPPxiG zjdpO6DrL$i`z4ncv0+mEZ53OB%=asJU`(E==Lhrya&+m1-|E&2Inq{D_PWJ;5AbfB zyTbRR&r%!iO`mQ4RxGlWR$%bK>ZF3ozekL-{2A)4)BwV&uCJpai(&cqfU^9c#;i&n zFV0iKSPI{uRUK8vl@|1xm8q5_z;GXnN#t#NF9cnV#jzT2%;3P4V9cU9L1{=`WOqt6 z70Ez?wwfaipJoSv{3Z)^LA{U1)DOF1YDn_}Tg(?*kY&8k+Ed-J{-`8F1@l&0S-T(_ zV-D&H`8LIi2vKdt+^|g`XEfJ{Ox8a0uXAISO;ZM!$}E=Ahy7F$+zG4kkb2(u`WX#MOa+z^nOTNy)4VGQg27^qy+Sb_;c0^$ zRp`UC!`Rn4^_BW!MS#h@wOGdLSGf@?#%_M3wL(m(exp?xj@J)HC}G%f5w{B0fcxKU`Q_FUc7Z<4#Z;p zfbFU^Ry-T9qQthIyn(fIFp-WWs*4_ohW5AtqsS@UIxxz+mVI6?RW8Iv%jv*sh`uv+ zZ-nj$)EIg#U%}>eKo--Su!PgJkAwEuVZjmd@KSvd3vJM_(4&GETd-M(HsKq861yux zfg2VSk2bSOqp~!)o{zhH51sLwmYRF4A;+S*U=VrBKKv&5N<1(?a=8xwW~^>6g@8vf zfL2>~Wg6Q$xm&B()H<{ECJOV%*A;1asFWCu7Q<+vj&)6PEr+>Qb$}jQit_GOi?t+1 z62fihKQ*isv7*9iELzvPj1_M_@2b7TErdlh7$dcS&f@FUavo0{ShP3kqrbONcS|Wz z4a$@gt)GP&YB$eyMo+uLx}%bjDS|4tEm)WK$s6WJmiC96{hE`L>e@l=A0RQSXMBuC zviV*-`C%rD&UVImDWux2RNwH5I6(rY`Y>&L71{>V$(`11EV)1a)HXI{m+cIM;Qx;z zNvrrxk^=H5SFs#!8i|#}moaaV>8 z;OSt?dij5f_Zwr=hdN~fOQPjT+7aVV9%(5O+dtI&*jf|KB1Ueq6ag`#d$Gm^f(ry= z){=zp&eoX$fiYtM5rm5pjyi^}@35aj^3)c7_8Vx~?m6Y2R4vlpJ<=x1&TV{rEKIZ#O_Sq-^e&g{SQgR_9Td5wyoD}Wha|KJvm&N2IObq;D)!WnJAf_g z|1tYyR>Mp*(w`pNX~;RKeQWGtG8e@d+zhwr$PmwDZs7%U=Ey5YUOpQ-z#Kh&`lXY{ z&vx3}x_baM(uj`OKO+P{$5Q0w}t{2X}CpIGj-y*Z*;breSv zX&j+KUXJXv^Eh%SX2v!(Jkf7=X0`zNJboO5j;?ryC3SEj!!lqggD*l99y(93nlGE<5l;2=$@} z`~YJshnIE?6Ygm7krv*2XRNoQFbf>8ye`8n!KVBXGhJ_C3S0(w!ZB6Rje~J+mvF@? zz`OQ#v1*D?Zj12RR5uu&9UW*&#_0gi&pv*h8&9p4`~H<{ru8SaeMOAd{ME?K({y*c zpXXn>X12IyVkVHk3$CoUJj>rBo*i=UwmIN2y#1ec4%lJ$oy8Muya3-J!~SO!*iDAj zbDr{^AvFc!BzStb{6WV*fEPp3qS-s1r;;gxHzu!RRj`Egu?br3zK|q=tRqs9!*z73 z6W!M0l!VbbW4xlwbI22NJ2QF|mi4d!{&8XlysAR)EG*PoWSKm=n>cG&qNS}^s~(>m zL0k2eF?5sNm)wR3EKOutK`jgIw8F+yP$EeaWYSx~<-dkt)xj>Kfrdj0(yHD%t@g`Y zOWtto7G~b>Xtyt?kSlcwdtu4iy^<7sEE*dEv#U-)?S$x?wg!Kylt`Fdko|WMEbUoM ztYHhoGIoNR$`FIi4a$cYTb*7@tF2CFoW!-1lV}d%JshEw(+F*A5{Q30^;!a3pAH~x z8r!0pdOHejgW`>QDo>+#gpy477H;V3*3^RSM>?`b#bHip%Bmow~|R>Tcp!kA$x$DO0-8b zG3t!cIVDc>*hkUm<__bY7IoV5$@?ZxEH>a;NoE_*Z}?;#F?#^rBWF&AMIBS;iiSpKr8udnL^+^DWN|Icn~A_U zh3j+ilvlU{Nx61Lxg4)6s6O|?g@`Ft6DbDVU3OB_Hax8gQv+Kn4T??_>X6e;EwWbc07A{aT zFijYlNGrN(KMPzJ!j6hp$zcLjDCHf-5Wvj&Yg}TdWi%8ETD#Kpq*-619%gFPF1paK zJNi4NvBsQP4}&41T4X@T?8M(FHbM!)w8(?!-7d?KqET zvr3>=*(O4|Sl8D5hXC?L+su?5BGI~y+k%-_I$HVxwX|bIdaCjYKu28QP*igDqinnTp<)iph#>q8CX`uST#AaiN@q;XfoZ0}^mLbf`-jtrn zNh3)g?Z#7zaoy}xTUrbB&3>=2;La?DP$nyBi9@bj)KJckShg5-7$e}OjqOR?U=-QT2w2;BGMcYI%1#%%; zP(sF#Jya|L>}`_DPu3w7Jy3q>X+^{NOF%3dl8=yDbcy#yE_BT0VuW1G@=O%m+oYF& zwGO@5lVE@8DaJzjOF%OgmXDBXY?=2)H_w1>Zbay2v({y7@eK0qiDu2kZgLquOo7#- zMK~Y1SlF(XYqVj~>V;~B{jOy%%T43w-NR_%uiF-;tY5!zyRfGP>+^eWv#nq0)>g%! zJ6h+Sk=_hk-7W$LKme)DP)jfkVmT_}l68l2=Pla)EZY9PNZZXpae5LlJ#a@YFaDo( z_(Mbhk9yHt5kz}>g$Dc<8}|PfMtt3FmLy^X2;&n$2K>TYqp_foKZ$Rbv>@Z7GU%Ir zXgBE_IJ*kDIzh-kZ$WII2C;qL65FtqSV$n(W!V|%dLr$<-eg`{w|-{1F9gvHu5j*WF^TY_LD1L~95-jrBvy0>EsZ4_p5U zbzx|vr{m1Dk^bpcn)BL|6goV1>@8j{`7}ADKe)_I4 z;fF}-w8YClplW}If>Q{Bm|Y0QSORPH zw%8J4|A5-l)sk-Z@sS6u@5XTcGhq0?QfCpWVL0EdNv=Cp+@_WMOY3w|L?j7Y7PW?3;p8K+ zk0^Zo;;rIgTVT6YZH#u(-C4vf+2W)ZwJZ{24}SslaSm)>8^JC5taxxM8~AhR{QqIe zpwAwbNrHlfphFBlid<21d_?r~aLErMb6l|p6Z&A?ZTkw5~BVH|klY&hY{9Ov@=w&%V z!QZFgM-;GZ!_(nEq{kmq@E!$Uqk#PBC2azfBvHuC6p-A)uBzeJN#y4#j@Imyv%??K z9T{W!TOs^?5J_6FBt?r)cL*!= z>u&{j#K$G@X$j%XC$Pzn&nzJkmrvbI@wu}&dvjajtI4IIMi0*6Om9o9IA3+(yfO)g zlnfkFX0%vKd3B1LI4PP-NK*;gB?bO8X;eG!$Yqh4cl`Hil!|io+8WuRrO~^ZXk=HC z{*U=>*1qkdJ9{R1PW0m=1IeZFM(R?Z7?@AWppjea$2D*>!Fh6P8{oOa_ki~yz&kjf z`=%p@EyNqe23z_d-Dy}iViUzs@%MqAqnMas(gG{h+uz6)zvg18NG@T+5;m0Z)K124 zFnHrM9o=Z67pt}Tdx4K2hH8i7MyF_N8GRXxKa#~!N-EH4@tuInG@DlOAs>NtE1bN5 zs73k?D|S`V7Q1jQR2|UlUIBP4&XChAGvGMmIo4^+vTDoLs=GhE&^{DfUFr zS?_2=0jH9~szS}hnKSCkbZVx@=7A-wv!KBdYq*O)XvO9$nJ$j_*nj=th_0R1Q%@>h}NLy|w5yH4N)9%X~U zYzbV^dzaUZr|`+L@?ZP~674x-BVC-=PWY5`=7c-`$N~4sC!TohiHYX^Xm7jKMQp9b z#twh+qWV}LvZ&U7NX)r}i~r~$hl_ZqKZ8IW0=;Ve$nY)M#5K4R(E&p@aW*9e0KVP1 zoctbqmK^;Jiq+&w>T&JC^c$mebpKjUct|vPG<{aTeI=p2n0lt0eUCi))a1kXe+1uL z!x6G{V&~7dpXW2)c`1sHn#6h^NOzwW%f zn;on9Scu3O)HK0Bo}a~!oI`-`ijx1eIz1wz@CJXPfhY!pG1`Vhp}xgU*=Kq@z|lsV~&EZ04ZnO1bvfqs{HS;ekdL_DF$V@ii#(9lH-{N}Zk< z3Q`q(`|Ai*p2fN7nE&X5%pl+(P2cGM`1}|h zj*BmORqFMH!a^ONW?l+*y;`ne!wEPbg!B}I2ZH`6B|0eNd5-Ag#2Izo&d=(`L4WG39p(LR*rw-mv4&hia zAU8=)dO1h_cdBR~H%7X+R~z~nIYaszZ2VmyeB;EI{{;zvBz)akeQ-B}En~p1k{O8Z zmDIv>cP~BCR24vpr2W&p#|*>;?;)0J=!^#&4==SkDxh&A`w`@aBQNRJnCI^7kQD-mCE*Rc0_{D_z&;MWV_T9Sx literal 0 HcmV?d00001 diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/types.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/types.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78c30c9a2c2ab9317dde00ab520158448068fb4e GIT binary patch literal 6289 zcmb_gOLN=S6(;z8NHQ%uaoi`u&Qu~tq8(40>9BUkiXCf`x^%{lHw+UP0a4qHe?|B0ALzQj0NZZbRrg)>I|l>_(va0m%Nbl=oO|vA=lR|9(fWF`fM4lv zYhSgm6bgT*m-JUg=Pn-g4>Wec*4)BW8)+K#i*9jR8kMHyQF&S!RhU+CtJB)3Hm#59 zT0v<01-oolzAf05*TvV`sKJz~T?3`YlqOT^b_0|KQ(8=E+AUC8Oj%>fn!OInI#bq} zvSD8W0hrv2WNLihSV z@wJ7n7B_4&G%eRus#}W74;>Z8<@=5m#`Q-*==h%Lc8hWAp<@Y8iO)|15pO*7Ez{jM zr{YOyhQgwS(q9RkyLeOwjVO#XyD%y~g-}N&Q5F@uc)P&=rP~l2j!}s>_M&hiywI^s z-VTBL0Y@m>(z zhc%2@!Q=f$`+duwj`vO1e04ew+z8@TV?3ehy22hG@xZR*h2lu^2D{#eKih@&q`xPv z$?opW{;e6;*bJO;2=h_>;8fOegesW{HO*RCt7}Zlx22E3bdfEMs-h;!*9)+*3fb1E zK7X&yY^@eo@A>|7N1XjR`Crm1NpGN2o9f15=By%Lnr;Lp{yU$`_NS)f4Rn9zKYBtq ze?nlwxEJj7n!_BI>2Ps{cOS;pWYxG#?A?ZJK}d3qnssV6sJTQeXy^OYN9S0qItcL(NPOD z;%t!Sh?~b2`l=7btMExE0&@eK{@b#X zR>D2p(N*XpWo7f$ouTiE?qF#(=TJu&&tYC>W6>q)Gj*Qr4m4#bWHqD!96hnZ9T+bK zLv?2+ogMvH-TCxMcPT1TVlz1HsR1T~dv348XTgSmertrI@bl?W-&&c?G-j`3ZlJ~e|h z5F-xg>(xl0EU|Qk!$M*0--}#Q3!AVoFQHc zd5WxHMN@(z03u1OQHXT34kc6x;59#Q23|mgNjw@gJOoFB=8ReyH4Wq1?0e$kvFff} zkQfO4%Inl{b|C!55nq0fdU<)`Jq-L9Pm(9zr##Wtu4CUx=V~bH2HbLE^*$j zbKEZ@PnQ@Irf~BUz_zo$QY)wI_quuDM+DwT=!{57UI+%jp|&1KAR;SVMr$)ZgRsXy zr(o$Xx~*4DJVxeuGplA3KUU|OZQ#t>@W9mTne}5 zfeZZX!#ZKL{=uuTR|73M?A9*u4H9Io3;YlRpOMrF$0qO<0G6ovavxwM-vKgm2SAzu zAjlt(C;_rn=%E&u>9%Fw#MOv8xw1dP01H3zoEMS6ZrqgxHxA`_Sp;6b2JZoam3R!jXo_YFQWrPY4P}4X`Z@T%? zbX>M5+K@E2I_ZD0g85o;jSmSsXmW>7J1Wx=V28A=g z6+pSrH{D_-UwoDWX9Sx zvdjj&@2HhI=`h~NjN<@tJNr)SSnwkL(SH%oJv@rC*KZ5Q1$7mzrmluXTYIhj4)qH- zU~zc%^G6;>tL$ot`vOf(((eR!J8Jm*W=@6Trd%KpKD9(8%mk$9Ig{tLzu&iW=tSigFtR zXLzz1nrPoyj8r~bh_U%VqK;(~HahzwwaW;}MBR+W(+_+bL|GPQPIZe&Sz*pgQg7C? z=B%Q;HCnJG@>Szs44zr~Xk8`1K`b_*ltT3;=#LIB(({RP0OlL>x~sAhhE zaKQGM#yFD`5+glazb#l zVoso*?PUHFU^v_Xg3o6WYAi6#L9!~E`A9YCF@KM85l94t$F5$T3+CzCD$?=@+DS30$ tljO6KEReU(J84|Mos?vMMVW;&%9?hy*e-6iHa9l6Hk+Gk_;2IC`5%S?d^G?7 literal 0 HcmV?d00001 diff --git a/galaxy/api/consts.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/consts.py similarity index 100% rename from galaxy/api/consts.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/consts.py diff --git a/galaxy/api/errors.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/errors.py similarity index 100% rename from galaxy/api/errors.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/errors.py diff --git a/galaxy/api/jsonrpc.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/jsonrpc.py similarity index 100% rename from galaxy/api/jsonrpc.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/jsonrpc.py diff --git a/galaxy/api/plugin.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/plugin.py similarity index 100% rename from galaxy/api/plugin.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/plugin.py diff --git a/galaxy/api/types.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/types.py similarity index 100% rename from galaxy/api/types.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/types.py diff --git a/galaxy/http.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/http.py similarity index 100% rename from galaxy/http.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/http.py diff --git a/galaxy/proc_tools.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/proc_tools.py similarity index 100% rename from galaxy/proc_tools.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/proc_tools.py diff --git a/galaxy/reader.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/reader.py similarity index 100% rename from galaxy/reader.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/reader.py diff --git a/galaxy/registry_monitor.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/registry_monitor.py similarity index 100% rename from galaxy/registry_monitor.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/registry_monitor.py diff --git a/galaxy/task_manager.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/task_manager.py similarity index 100% rename from galaxy/task_manager.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/task_manager.py diff --git a/galaxy/tools.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/tools.py similarity index 100% rename from galaxy/tools.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/tools.py diff --git a/galaxy/unittest/mock.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/unittest/mock.py similarity index 100% rename from galaxy/unittest/mock.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/unittest/mock.py diff --git a/manifest.json b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json similarity index 100% rename from manifest.json rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json diff --git a/plugin.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py similarity index 75% rename from plugin.py rename to n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py index 846a6c2..96b0047 100644 --- a/plugin.py +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py @@ -8,10 +8,12 @@ from galaxy.api.plugin import Plugin, create_and_run_plugin from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime -class RetroarchN64Plugin(Plugin): +from version import __version__ as version + +class Retroarch(Plugin): def __init__(self, reader, writer, token): - super().__init__(Platform.Nintendo64, "0.2", reader, writer, token) + super().__init__(Platform.Nintendo64, version, reader, writer, token) self.game_cache = [] self.playlist_path = user_config.emu_path + "playlists/Nintendo - Nintendo 64.lpl" self.proc = None @@ -23,23 +25,22 @@ async def authenticate(self, stored_credentials=None): creds["user"] = "RAUser" self.store_credentials(creds) return Authentication("RAUser", "Retroarch") - + async def pass_login_credentials(self, step, credentials, cookies): creds = {} creds["user"] = "RAUser" self.store_credentials(creds) - return Authentication("RAUser", "Retroarch") - + return Authentication("RAUser", "Retroarch") + async def get_owned_games(self): - self.update_game_cache() - - return self.game_cache - + self.update_game_cache() + return self.game_cache + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache - def update_game_cache(self): + def update_game_cache(self): game_list = [] - + if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) @@ -47,33 +48,29 @@ def update_game_cache(self): if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): if entry["label"].split(" (")[0] in corrections.correction_list: correct_name = corrections.correction_list[entry["label"].split(" (")[0]] - else: - if entry["label"] in corrections.correction_list2: - correct_name = corrections.correction_list2[entry["label"]] - else: - correct_name = entry["label"].split(" (")[0] + else: + correct_name = entry["label"].split(" (")[0] game_list.append( - Game( + Game( entry["crc32"].split("|")[0], correct_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) - ) - + ) + #adds games when added while running for entry in game_list: if entry not in self.game_cache: self.game_cache.append(entry) self.add_game(entry) - + #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) - #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed async def get_local_games(self): if not self.game_cache: @@ -82,20 +79,19 @@ async def get_local_games(self): for game_entry in self.game_cache: local_game_list.append(LocalGame(game_entry.game_id, 1)) return local_game_list - + # Only as placeholders so the launch game feature is recognized async def install_game(self, game_id): pass async def uninstall_game(self, game_id): pass - + def shutdown(self): pass - + #potentially give user more customization possibilities like starting in fullscreen etc - async def launch_game(self, game_id): - + async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) @@ -103,15 +99,11 @@ async def launch_game(self, game_id): if game_id == entry["crc32"].split("|")[0]: self.update_local_game_status(LocalGame(game_id, 2)) self.game_run = entry["crc32"].split("|")[0] - if user_config.core == 1: - self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/mupen64plus_next_libretro.dll\" \"" + entry["path"])) - if user_config.core == 2: - self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/parallel_n64_libretro.dll\" \"" + entry["path"])) - break - - #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings - async def get_game_time(self, game_id: str, context:any): + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): file_path = "" time = 0 last_played = None @@ -121,7 +113,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["crc32"].split("|")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) @@ -129,8 +121,8 @@ async def get_game_time(self, game_id: str, context:any): min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') time = min_data.hour*60 + min_data.minute return GameTime(game_id, time, last_played) - - #checks if game is (still) running, adjusts game_cache and game_time + + #checks if game is (still) running, adjusts game_cache and game_time def tick(self): try: if self.proc.poll() is not None: @@ -140,13 +132,12 @@ def tick(self): except AttributeError: pass - self.update_game_cache() + self.update_game_cache() self.get_local_games() def main(): - create_and_run_plugin(RetroarchN64Plugin, sys.argv) + create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() - + main() \ No newline at end of file diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/user_config.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/user_config.py new file mode 100644 index 0000000..41e01cf --- /dev/null +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your roms and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "mupen64plus_next_libretro.dll" + +core = "" diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py new file mode 100644 index 0000000..b650ceb --- /dev/null +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py @@ -0,0 +1 @@ +__version__ = '0.2' diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py new file mode 100644 index 0000000..3f4248c --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py @@ -0,0 +1,4 @@ +correction_list = {} + +correction_list["Final Fantasy II"] = "Final Fantasy IV" +correction_list["Final Fantasy III"] = "Final Fantasy VI" \ No newline at end of file diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__init__.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/__init__.cpython-37.pyc b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3160d4f718954ce6fa7773204c4406c2a04b69b7 GIT binary patch literal 311 zcmXv|!Ait15KYr{U3OXg1Mhoi5fKlvh^t~Ri-NM35*$J^wAC~TNh-T%kN$)nz56x2 z`WK!|iw?XuGrY&lyPVIbB-Gd2?C~b_?|%8OQ5Y@)++m;%K_bX&^d2F__=DyYN4O`V zB@y(2EaH6MJeRGnWj6sZ+*bg%i*Yvvs2iiL2gql*^{B+4+9=%Yt%^4Y(8bQ%?f`%* zr9JnxRu15k*m1B8^z(9c#x@SEV^6N)1zQ<&%{^ypU2w^=yDTkq!!j=UcE^lt%UU@W z;JK72SUCtutvr@?c#x>mljI(~)hk<6Nph4P|G8KQt?CdtHM?%IY_w=4p7)6z4MfQ= D9yMLS literal 0 HcmV?d00001 diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/reader.cpython-37.pyc b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/reader.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9739dc32aedad237a3a2685bff5955a2a068355a GIT binary patch literal 1057 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 literal 0 HcmV?d00001 diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/plugin.cpython-37.pyc b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/plugin.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5f1f7c7c6e98bf336400c9a11e523bd1cdfff47 GIT binary patch literal 32048 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 literal 0 HcmV?d00001 diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/types.cpython-37.pyc b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/types.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec0b787871b3d4f40b220bf76af30350bd627379 GIT binary patch literal 6289 zcmb_gOLN=S6(;z8NHQ%uaoi`u&Qu~tq8(40>9BUkiXCf`x^%{lHw+UP0=q#>)BmNU4#IQQHK&hxwHgZ1@h0l#nm z*!tpRvrza4y`;Y~I(PA?f1R zQ#R~Npj=|g229y*rrl=$>C5b?%C-!k2O zb1I&MW+*ILDE*btxr;}2(1^lFvkRl*QwVib5@k`bi?<8>U%Cyk;TV;8V=oFP!V4YC z|HLe>5bqynK8phPOBbWMX zhVddY-PuUZFl^rfxuilFUos5S^L!`@U#JY@uLXG-s^R9}C-(;9r}!E*K2{Uc8t(3H9C%~z-6z>OeYHO3Q~t}E>E5fAJ-UMP+vZ?NmV|BGE{Px^b( zn(Xf0?BAMkjm^LrhcF-24^CwrN2rpSP}8iHwYtW%d|UbeOc&YGs48lre7yi0tB`Gt z>ht&N%+_jg^`7rPcf{GZ$^Vj8NqPgF+Eh0dGiMd~(sUy*@!$Dewm&r;Z=m}#|Irh| z`4a*Y#=T&t*Bs`!Ooxjry!$Y&CacC}V(&I&3qq1>)T~ppLCqy<+SI&FO@*3k)XYsF z=^-Q*SCw4bi+_$mpmA z8gVvAbHvSK3w_mx;#K%06oI*cP5*CEfg*w*2rrEV2a)53Fk}5d`X_J){ltL=j+~cZ ziKN?S6L^4VBVL))z!ihUaQc1S@@=tu;QH3{!9WjA!-?-PGliXj>v+QR)r^}+pX@!} z``r`$j{fDGi^!c5$FuztBXmMnbOw6oAq6rzW#F12MXt_H?t~M^4kwuKtD86T879Ix znuOqZ|1}Ocu?&q!Iv9QW_~FWY;n_51NO2@yfkSq_n1o@V1~+b`XEIL2fpS7IrXX>n z-|Nhe_*Ehw`m~SvYyX?s1O2o40yI-@ZEY z=(itzNJsfB@%-&h^8a3ENALL0sqLrVMW55@F3V$i)~$1AEuH4`g&0hA>RGxi4znpo zY%Ad&?&vD?k+QOR>(0>kM0c>XnscZljOQ>fv$5!s^qD%(b_bd=6tWsp0FItm;SP+K zf}y%Ilg^HQtnPgLq`MRqDX|%x_S66qyhG zYyRlmQqy5%6`Gti)0LrB8%>94+pk!5vxGhS(81ilk=6+x=t_hXXlLWuDaZIOQ=gha z8i)}Gban+QB{K&QS1A)*XtSAIH0L~CE zg*-)8u%anJ5de`S)+j_eT89#<1n`=lHv=!A!XzGz8Xkh9L32i}jGBgVZT3BJ@mO`& zE=UZ7e&uy)I6Dx2 z*E#N&k*7H>T_Iuqt@FN0mBy>ikBrgO5;80r+BoL7mE~B*>pF!AT zpi@gzX4I_8{S!~fOd95Q;-%eIw%8$GHY1nx?CVw}B@_w3m{C*~2aeXVrx?Z+Ui+M+ zO^_%_+CQQmtA!JJo-%$2mIrteAXQ}`MYQj$h!m6~9an5pL;|iz*!EOpk$YdcZDsL7QuGF>^JHk-e_9D=%fhJ zRJzotN}DjIfoA5%*=*~V+S(ArRv0S z5nTjgnNkoP?mZmi1>%klXVdAr<=cE4n$W0gMxHe>XoajgdCxq3t};Re2&ieCq&MAs zX*w=j6m3YFTb=a3SiyX)xWoOBp(WX5rTxSf5cbu4%h|LDJn=N=wK+3WX(n0 zIAC#j_RB{eN2}~=iR7{rJ}3N3iWiXsP}eQV4i=U_LxV~OoK4a_{6i^yiP}q<0AMD< z&^;_(H;lknp+WjGjF%uOO40>wLp!^^w-8-_iLcwrGvl}fERiG=KF1=N(23;FsmV(# z?_%H>Ph!h_KW}U8q9l60Ez|Pngg8nmi+WFkiwpZu#Mz&zWlT@SmOu){iG>P3A}5t@ zDI}k102C#cEp_JM*@6s_vQ|kK2bbd)5EH<{06-dl+0e-BQpb0U+NDe@zsgntQil}D3 zhj765n8rAh6A~jmT;vJR3t$YCzLF@YP(TO~RF_ki36T>c>ZyRK6fNnEtWiIqEbE%5 ztK~!*yI11Y zynHQ`q-Y*jf$<&hNS5#-`Fca^$Cs08Wgm4&-0)GuRFaW4J1*h4EFwb(9hwl+65w>F!bYxr;Dzxf~U*L?~A literal 0 HcmV?d00001 diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/consts.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/errors.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/jsonrpc.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/plugin.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/types.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/http.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/proc_tools.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/reader.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/registry_monitor.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/task_manager.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/tools.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/mock.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json new file mode 100644 index 0000000..5aedd5d --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy NES RetroArch plugin", + "platform": "nes", + "guid": "e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad", + "version": "0.1", + "description": "Galaxy Plugin to add NES roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py new file mode 100644 index 0000000..5cb09a4 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py @@ -0,0 +1,141 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.NintendoEntertainmentSystem, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Nintendo - Nintendo Entertainment System.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + if entry["label"].split(" (")[0] in corrections.correction_list: + correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + else: + correct_name = entry["label"].split(" (")[0] + game_list.append( + Game( + entry["crc32"].split("|")[0], + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["crc32"].split("|")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["crc32"].split("|")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["crc32"].split("|")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/user_config.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/user_config.py new file mode 100644 index 0000000..8f6cb99 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your roms and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "mesen_libretro.dll" + +core = "" \ No newline at end of file diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py @@ -0,0 +1 @@ +__version__ = '0.1' diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/corrections.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/corrections.py new file mode 100644 index 0000000..3f4248c --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/corrections.py @@ -0,0 +1,4 @@ +correction_list = {} + +correction_list["Final Fantasy II"] = "Final Fantasy IV" +correction_list["Final Fantasy III"] = "Final Fantasy VI" \ No newline at end of file diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__init__.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/__init__.cpython-37.pyc b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3160d4f718954ce6fa7773204c4406c2a04b69b7 GIT binary patch literal 311 zcmXv|!Ait15KYr{U3OXg1Mhoi5fKlvh^t~Ri-NM35*$J^wAC~TNh-T%kN$)nz56x2 z`WK!|iw?XuGrY&lyPVIbB-Gd2?C~b_?|%8OQ5Y@)++m;%K_bX&^d2F__=DyYN4O`V zB@y(2EaH6MJeRGnWj6sZ+*bg%i*Yvvs2iiL2gql*^{B+4+9=%Yt%^4Y(8bQ%?f`%* zr9JnxRu15k*m1B8^z(9c#x@SEV^6N)1zQ<&%{^ypU2w^=yDTkq!!j=UcE^lt%UU@W z;JK72SUCtutvr@?c#x>mljI(~)hk<6Nph4P|G8KQt?CdtHM?%IY_w=4p7)6z4MfQ= D9yMLS literal 0 HcmV?d00001 diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/reader.cpython-37.pyc b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/reader.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9739dc32aedad237a3a2685bff5955a2a068355a GIT binary patch literal 1057 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 literal 0 HcmV?d00001 diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/plugin.cpython-37.pyc b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/plugin.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5f1f7c7c6e98bf336400c9a11e523bd1cdfff47 GIT binary patch literal 32048 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 literal 0 HcmV?d00001 diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/types.cpython-37.pyc b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/types.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec0b787871b3d4f40b220bf76af30350bd627379 GIT binary patch literal 6289 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 literal 0 HcmV?d00001 diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/consts.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/errors.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/jsonrpc.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/plugin.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/types.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/http.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/proc_tools.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/reader.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/registry_monitor.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/task_manager.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/tools.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/mock.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json b/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json new file mode 100644 index 0000000..42c5476 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy SNES RetroArch plugin", + "platform": "snes", + "guid": "bc831044-f772-4391-8c22-529f42cb9799", + "version": "0.1", + "description": "Galaxy Plugin to add SNES roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py new file mode 100644 index 0000000..2f30d29 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py @@ -0,0 +1,141 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.SuperNintendoEntertainmentSystem, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Nintendo - Super Nintendo Entertainment System.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + if entry["label"].split(" (")[0] in corrections.correction_list: + correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + else: + correct_name = entry["label"].split(" (")[0] + game_list.append( + Game( + entry["crc32"].split("|")[0], + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["crc32"].split("|")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["crc32"].split("|")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["crc32"].split("|")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/user_config.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/user_config.py new file mode 100644 index 0000000..cef5571 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your roms and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "snes9x_libretro.dll" + +core = "" \ No newline at end of file diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py @@ -0,0 +1 @@ +__version__ = '0.1' diff --git a/user_config.py b/user_config.py deleted file mode 100644 index 47dfd25..0000000 --- a/user_config.py +++ /dev/null @@ -1,14 +0,0 @@ -# enter path for your roms and RetroArch here. Put them between the quotation marks. -# Be sure to add a "/" at the end of each path. -# example: (this is also the standard RetroArch path, so if you didn't choose a specific folder for installing it, -# you can just copy the example path) -# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/" - -emu_path = "" -rom_path = "" - -#Select the core you want to use. Standard is Mupen64Plus_Next. -# Mupen64Plus_Next = 1 -# ParaLLEl = 2 - -core = 1 From a6f9dd73d6b050084b8d56953bfcf8469740353b Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Wed, 1 Jan 2020 11:46:25 -0800 Subject: [PATCH 02/37] UPDATE: nes corrections Will continue adding more as found --- nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py index 3f4248c..60de926 100644 --- a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py @@ -1,4 +1,6 @@ correction_list = {} -correction_list["Final Fantasy II"] = "Final Fantasy IV" -correction_list["Final Fantasy III"] = "Final Fantasy VI" \ No newline at end of file +correction_list["Dragon Warrior"] = "Dragon Quest" +correction_list["Dragon Warrior II"] = "Dragon Quest II" +correction_list["Dragon Warrior III"] = "Dragon Quest III" +correction_list["Dragon Warrior IV"] = "Dragon Quest IV" \ No newline at end of file From be5e23fdb2f9734d5043d9204e7225b6e61d75dd Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Wed, 1 Jan 2020 12:26:10 -0800 Subject: [PATCH 03/37] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f5137b1..29beab2 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - Super Nintendo Entertainment System - Nintendo 64 (Riku55) -![screenshot](https://imgur.com/HxauLPS.png "Screenshot") +![screenshot](https://imgur.com/A1Zk5Zt.png "Screenshot") ## Tutorial From 6620bb33a12eae9a1df93000b2c9aa5b5a6ba255 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Wed, 1 Jan 2020 17:42:16 -0800 Subject: [PATCH 04/37] ADD: ps1 and psp --- README.md | 2 + .../__pycache__/__init__.cpython-37.pyc | Bin 311 -> 0 bytes .../galaxy/__pycache__/reader.cpython-37.pyc | Bin 1057 -> 0 bytes .../__pycache__/task_manager.cpython-37.pyc | Bin 2006 -> 0 bytes .../api/__pycache__/consts.cpython-37.pyc | Bin 4042 -> 0 bytes .../api/__pycache__/errors.cpython-37.pyc | Bin 6360 -> 0 bytes .../api/__pycache__/jsonrpc.cpython-37.pyc | Bin 11968 -> 0 bytes .../api/__pycache__/plugin.cpython-37.pyc | Bin 32048 -> 0 bytes .../api/__pycache__/types.cpython-37.pyc | Bin 6289 -> 0 bytes .../__pycache__/__init__.cpython-37.pyc | Bin 311 -> 0 bytes .../galaxy/__pycache__/reader.cpython-37.pyc | Bin 1057 -> 0 bytes .../__pycache__/task_manager.cpython-37.pyc | Bin 2006 -> 0 bytes .../api/__pycache__/consts.cpython-37.pyc | Bin 4042 -> 0 bytes .../api/__pycache__/errors.cpython-37.pyc | Bin 6360 -> 0 bytes .../api/__pycache__/jsonrpc.cpython-37.pyc | Bin 11968 -> 0 bytes .../api/__pycache__/plugin.cpython-37.pyc | Bin 32048 -> 0 bytes .../api/__pycache__/types.cpython-37.pyc | Bin 6289 -> 0 bytes .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 141 +++ .../user_config.py | 12 + .../version.py | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 141 +++ .../user_config.py | 12 + .../version.py | 1 + .../__pycache__/__init__.cpython-37.pyc | Bin 311 -> 0 bytes .../galaxy/__pycache__/reader.cpython-37.pyc | Bin 1057 -> 0 bytes .../__pycache__/task_manager.cpython-37.pyc | Bin 2006 -> 0 bytes .../api/__pycache__/consts.cpython-37.pyc | Bin 4042 -> 0 bytes .../api/__pycache__/errors.cpython-37.pyc | Bin 6360 -> 0 bytes .../api/__pycache__/jsonrpc.cpython-37.pyc | Bin 11968 -> 0 bytes .../api/__pycache__/plugin.cpython-37.pyc | Bin 32048 -> 0 bytes .../api/__pycache__/types.cpython-37.pyc | Bin 6289 -> 0 bytes 61 files changed, 4186 insertions(+) delete mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/__init__.cpython-37.pyc delete mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/reader.cpython-37.pyc delete mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/task_manager.cpython-37.pyc delete mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/consts.cpython-37.pyc delete mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/errors.cpython-37.pyc delete mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/jsonrpc.cpython-37.pyc delete mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/plugin.cpython-37.pyc delete mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/types.cpython-37.pyc delete mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/__init__.cpython-37.pyc delete mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/reader.cpython-37.pyc delete mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/task_manager.cpython-37.pyc delete mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/consts.cpython-37.pyc delete mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/errors.cpython-37.pyc delete mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/jsonrpc.cpython-37.pyc delete mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/plugin.cpython-37.pyc delete mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/types.cpython-37.pyc create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/corrections.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/__init__.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/consts.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/errors.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/jsonrpc.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/plugin.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/types.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/http.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/proc_tools.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/reader.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/registry_monitor.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/task_manager.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/tools.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/mock.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/user_config.py create mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/corrections.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/__init__.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/consts.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/errors.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/jsonrpc.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/plugin.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/types.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/http.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/proc_tools.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/reader.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/registry_monitor.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/task_manager.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/tools.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/mock.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/manifest.json create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/plugin.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/user_config.py create mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/version.py delete mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/__init__.cpython-37.pyc delete mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/reader.cpython-37.pyc delete mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/task_manager.cpython-37.pyc delete mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/consts.cpython-37.pyc delete mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/errors.cpython-37.pyc delete mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/jsonrpc.cpython-37.pyc delete mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/plugin.cpython-37.pyc delete mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/types.cpython-37.pyc diff --git a/README.md b/README.md index 29beab2..aede59e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - Nintendo Entertainment System - Super Nintendo Entertainment System - Nintendo 64 (Riku55) +- PlayStation +- PlayStation Portable ![screenshot](https://imgur.com/A1Zk5Zt.png "Screenshot") diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/__init__.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/__init__.cpython-37.pyc deleted file mode 100644 index 27f75f5cb28b13c24e165b1a6dd43333a61ee61e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 311 zcmXv|!Ait15KYr{T~-(W!22FrM8tzE;;PWgqM+=h1c%TJZ8S|nl9k=FKcYwf#V_d9 zzwl&Qbl|<2;XP*F^=vjJvC^-@ry}<6e)+Fa9Ihh#aildtBFJ0vo*>2agO(IWxF?bY z5%iJF({kUw)SDnxHvzCbGyr$=aW@5!tu-zHln1yCsKubVB;M?;nzsnhr9Qas2!LyC z0uRz?5AaFcINApKdA+${Ymd&eXW#IOEv>!ffwPsVxMIb9kyoZ=1y_8#V^(cysXdDb zTqzW+9)wKlfVCSQWcvIpd%)n#(p62Co#dx~E*3*;86)R**G(3!5gUbZxKu;0i!1fGQ92?CO$YU5rf@X=}Bby98Xy3T}1MHyo+0J-c1VY4V?1ZQR2a7;xjRy2=<6m_5tI2Uv^&wCsHVLK$17 zYm|eUD{u;E13W8q9qj*TyQtr5)o~=L9BCnnB_T$cnk0B`2yrrz$vjdQLS>N<1|OWsT4`$w|E}K=qZ?od@0=2VvXT$pg DW0n4A diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/task_manager.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__pycache__/task_manager.cpython-37.pyc deleted file mode 100644 index 505cb45bfa30485f89a100cf93d404eb9546cc93..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2006 zcmZ`)Pj4GV6rY*>W5;$v8v;cwNXsFB1-76W?KCQt-_r)-d$qe3a=J-_EX!HQO*F2(Hs%oO1S(bR1r{v1!NUKCi%x z$E$n+dY{)|orbCJL~8%5D2sYR#%vlBdu$uf)NL>YEl5Z-JtojI?bzPs1VYg5rf0k` ziv~jCH^vV&gj5o7nMFGYEovc~%(zOVyT(QL--tve|DJ5Db-z(Us_vfZM{)PTaJU)i zsQW07qqMvAc&i=fgYH(8M&rZoFdg-hOm(5@C`|?L_UwyRl4;SCkxufgmEHTe^+afy zKag?1)%vh~ck0zeXyKqtw>>;G)i6x5M28_%k|-Yxp+2qB$;uor?U~a~?4k!;=#Ji) zr1}!f2|1xB?8Mo1E%ntSSCdm`j~>$_PqQ`&*_n0`0%b$ zc20 zy2g!jnVUKnDwc^wVs7@MjHe>J_*}^n(tk#nWC-ac`;n?fHn;@ri<)X==thkmF`ztR-#&xrk;74KgGVmZ{GP zwIrcZ=JFc4ypHA;nm5qA3dXny#HQMG0umtcC~81cXo$1CjCn6X_LE!xlj$Yt z<7Tx-S^y8@+XdMJ!Jm^&@?GPI7W9mrgS?+NP?Y7t=2-B?|1nBOLK(l5SCf^=+<_=% zb?_LoC6aH!)4R}=Jt&|eljTdutZFWSLdW0ni@RXPAIv}@Ahm)(G~06u*q59o^k-V` zRe;H+Fr964Qwlwj*_?8AfJGFad=rhWiQkKKACF)Q-grp3x&q(02T`KmM>O^y#~5(y z!acb$p9MO>ziFpg!SfT3@-Q^DFdXE31Og6CBMhI9qO^P?u~@crmL2&v4lQH<8e3gt z0zoS*c$HSY+MTK^akg2pIasZMxN`-{!dh diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/consts.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/consts.cpython-37.pyc deleted file mode 100644 index 813fb1cc53b31e3d7829ef26e36f7da7a3b9da65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4042 zcmdT{$$A^d5eA8UAqi2`zK!ISwn$5$EZedqiz`HlB4~jW=`z1Sjj0AO)?fzd?jZms zx8#}!$t@3(CzxwaeubP;H3Lv|K3b<-0(?``Yjt(i|5x{{OidLM^h^G;^oQDXBJnS- zjDIOAJflbNlE4H`xQXrrOmrtglMSqIw3iRL@5B zG}UvMr+Qva()bJ(>WTT{ucSFKKN+Uhy`USWHeAuRxoP~Hpu#hHv`ZzKfC)^%Bu+pQ zCn1GNNMnjrr6G$M$YB=pn1cf5p@;=2VG*XV1k*SLGdK-poPl##hFLrZ=W!P1@H||= zIk<=y;1XVh%XkT{;AOaqSKu06h3j|?Zs2vei8tUD-UJJ8!ELnQ4&H{3@D6;8AHgU1 zF?@=jz-Ra=e2$;NJbn&e;5>YZU%*}b5*F|-EaC!ug^O?xzk>UC4=Q*cmaqa3a0$M~ z2k;QThHvm8e2d?}cla$l!tdaFd<2j2dw7D6;VC|WAMhzW!yjN7pTP<)!z!-88m_`R zuE7SbLlrmRIaXm4pTi5>gqQdNw(upq!Y$awS5U)k*ufh7h&%8ae}p=|hFz?~9`3>$ z+=G350|&SdKj8rY{zRdHFt_6h)ACjK_-Z}q^?YR{T5ZvFytdUFb?Go0c<(&F??JNd zw;`>K6kSO6v$`UA*xf^O55kbftXQ4Y2xq#R>+AnWOVR7iV$#|a?CFGzcK(4-;C@eSnrLdEt% z&ZB~C`a?(yBNPp!eY6Fnv?n!Wn`AVS`AMuGVad_9uaJP{8`%vY&vQF|pe1dx+lEYE zM)pr0lYK{yc6{1B>q+V^3H#v#a)2$#8L}-%sV9BNaCxZ`ziou%5^|$3fuhMhJz!Cx zu{1gJi)aTz5zuYOL{6kNMOPftI+JZuC`n8kaf%jEYXDTF*mr!V-yMPneG1k^_eVm@#p<@`N90E73AgV)M1s z>_80}F9?og7qYEEyhh&f&>^q6BHmTY;e~xWa04BtXmzta|8tmYG(1w%XoQ7Eqw8bf za=FxK{4o&js3+TKpl?%ic5e*UDucBigLQ|&da&XTY7EwD3|7e0Mqu5G!MgGutm_e2 zSH@s1?ZjZ+amBHK((?$hD}jfM+m|P}&1@2GH+`7ic040J^y{%lO5^&zYCS-F>Lw^4R-ZQ7ituV@$W0WsPD7T2eQjN!~)9T6y$LmVZ)Q7}aa{9`M z0i`wavTZ7B$~huutc|I#=9_h<0x$hMqQZ1lN$I^39yL))fXu!m2MnPAgL;otsGtj_ zRo|7i|0d?hwGUZPu2ZOA39?3wY$~q$T?#0{jX06pvDZDy9TJK>kb|Th0?oGct3M_ZRY+c4lsT#qQ_IU_>y~UYKWZ%sH>9`)N-Ip-We@9q;8IKi$3^PZP&g@8Ah$)D zL|ay1dc*OAONJPshxhOQSDk#j%LHfjrG4U@-`DksNWFL7muazm}P9_NPLQEqtkJ~y!c>L{;&R#^=IG5p3`>zH}^5RYn%(S8vg z(|JHxvRZMAwoFGD%NCy1l$O)&xsvl1T}7HhiyBV_WwBtNTE@3@ ze1EYhoR+U>+R)OJqnwswzh9@N{4OmfE!WW|)}>0c&2VnB%eSA+F&-G2IxNSnwZ11Y zZYgYuz_UAC35%oAQDth+8-XdR&c-vB?T#bq#G~Ela5`?|{#}RWr&ej#q$OH8f^JxN zLzk%5FxD7%Y$%5g5*=QwcLGDi^@t16m2K6f4inDB7X7w$dwy2sXkRMNg0KIo$b$E( z&as$fah}B-iwi6+vbe1?7PlN*dRp!dd-8bW1Ws-8EJZfzvJCa2({(!MT)7hv<%O1EVS`SNbnB ze8#TQ{BVHYCl1B!XZwd^@caHD-(KhHa!V$3UUr3J#2r0-qD%c&#_J|ZIMNAZn@ouxI*J&v!qPubi^bvar7xja|S{RiaZ BlY{^O diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/errors.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/errors.cpython-37.pyc deleted file mode 100644 index 643a68fec78bfa137223b7c36f28285a8a236c4d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6360 zcmb`L%Wm676oyGjj&G7JS&?sXV&>YqjfFO712jm3+PN4m95+ZDpfH3I6V6y>LXpal ziesUxra-oRgm&3C>7q}dZCBZM)m8s9;y7_)pdhCVG~sUv|D5x2ICEy!E0qZgSMksC z4_mnVi-f*PxcLU3_@-@H+~Rg%b?jZcQ`jxomMFyYN%1Fp!{P;A{Mq6~VZSQwmY_>~ z1bRg25$IEV6na$YQ_y349C}>oQRoRi2|cOw7<8Fepess`Lr?K(=xL=VpsTzFT~m4z zdWP4b>q?iQPxA(JL+J|iET4m(Q+f(|o-aTzC_N3m$j?BZQMwAf#Lq&XRk{X!jxR$m zD?J14@bl2;m99fy;1{7UDt#LI62A<6S?LD9^2l2G=nXMlDGaJ>-EQD}Eb*i8u9T4+ zlplxB!suYAJo=fw2tQr%IW)rBwYjxh;HB5ro~@9N?-pNMC2O~Y4n_vEYrSM&gh^`t zP(1JXQt)lBpiC@_{vW}oh);YPO=7*WeziHwezbop+IYe+DsI2Q!?yZ!$L$=M`XPsr zxYreOFy^{`=qIj=Kb2w;>rXM-oDX#919t@p2qvkYXQI= z{H(PVc`Rsce7Di`qE2gr1?=Tvs~hy%ei*m#NmvjF-fF8aR{by$ZK<%WhS$GXeJBzc ztx0cxb@j96we(jl*7aM0sK(9ip&UgXrzlxP!0s;}Pe~JM9_3+ggCo)kSx30;V8V4f z5$^?bUv}N+Jr)eVQO<)(nx!X#z;)#$dQ#1bE$Nk#Woqad5_A>An60$IL$0qg@0kes zc9g8WV1B@!1R}>1CLsMr>g9w{;xO&DQjo+SJ0j{OIk?fQ)30f634FK%r)M$n48W(8;T;()2HBIG!MMJ+|(XiBOnGMv8&c(8i2CE9j3{wNPG?FwX(gv7j z)A>71^r48mQ5ffVE9EQ#=`U+wQW~y zZ2)t6CyMT~@Q~(a5hrnuVMnuV{{noR4vf^gMNUm;M;;-5PLApBg3g7eyLW>z!?Xn3^V%AiNE=|PZAI<2 z;G5yyAQlJvLgtw1-s~ZUetqaf)0@y<*@o8@ofe2Keh`-#HX6E@wLh?tHo#VSAfqJm zq9E_^YNP-Dly=E8zCO{}fYz)w`XAO)2fi!X8>C1ZkgD#8PB)TF9 z{J+wD03m4ug!Qx*ypQ3=+9H00nkzd%|0>R0`m;wxYbSMcV%TNlcT496eoH3=XGV|K zkgs(Qphwz(USl)#BB~+6WQ)Z~3NZ)2Ux?qOJO19DGDC$QCr(Py@v%t{ohQSSlWa8C zb*?lTPYl+K=cn*r(|v#^X#<|qnqZXNi+VUF%6$0e|4k%osgF7u+9RiI*zW0+fNlQ0 zn9P7(h4WqQ4q&7WfK@lC)<`x()I)9TxO424{O=1Owm39$SRe)Chg8&dQ_F1VzSYSA z-NO6PnX#jaV^iCM9qH|r*}=^4jJ1i=)8^AS3T4-m$jOpLG=r(N7^k;K=hf;2Q_}f~ zr1K?7n}12WezkE`n=rM-PzAH91yyyUR&=#qsTo&I9BQ@5sSE2j0XS{N%-OaJxnUte=>u>b%7 diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/jsonrpc.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/jsonrpc.cpython-37.pyc deleted file mode 100644 index 0c6db1f5fef7f6cc1ade07275327009437318ebd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11968 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 diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/types.cpython-37.pyc b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__pycache__/types.cpython-37.pyc deleted file mode 100644 index 78c30c9a2c2ab9317dde00ab520158448068fb4e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6289 zcmb_gOLN=S6(;z8NHQ%uaoi`u&Qu~tq8(40>9BUkiXCf`x^%{lHw+UP0a4qHe?|B0ALzQj0NZZbRrg)>I|l>_(va0m%Nbl=oO|vA=lR|9(fWF`fM4lv zYhSgm6bgT*m-JUg=Pn-g4>Wec*4)BW8)+K#i*9jR8kMHyQF&S!RhU+CtJB)3Hm#59 zT0v<01-oolzAf05*TvV`sKJz~T?3`YlqOT^b_0|KQ(8=E+AUC8Oj%>fn!OInI#bq} zvSD8W0hrv2WNLihSV z@wJ7n7B_4&G%eRus#}W74;>Z8<@=5m#`Q-*==h%Lc8hWAp<@Y8iO)|15pO*7Ez{jM zr{YOyhQgwS(q9RkyLeOwjVO#XyD%y~g-}N&Q5F@uc)P&=rP~l2j!}s>_M&hiywI^s z-VTBL0Y@m>(z zhc%2@!Q=f$`+duwj`vO1e04ew+z8@TV?3ehy22hG@xZR*h2lu^2D{#eKih@&q`xPv z$?opW{;e6;*bJO;2=h_>;8fOegesW{HO*RCt7}Zlx22E3bdfEMs-h;!*9)+*3fb1E zK7X&yY^@eo@A>|7N1XjR`Crm1NpGN2o9f15=By%Lnr;Lp{yU$`_NS)f4Rn9zKYBtq ze?nlwxEJj7n!_BI>2Ps{cOS;pWYxG#?A?ZJK}d3qnssV6sJTQeXy^OYN9S0qItcL(NPOD z;%t!Sh?~b2`l=7btMExE0&@eK{@b#X zR>D2p(N*XpWo7f$ouTiE?qF#(=TJu&&tYC>W6>q)Gj*Qr4m4#bWHqD!96hnZ9T+bK zLv?2+ogMvH-TCxMcPT1TVlz1HsR1T~dv348XTgSmertrI@bl?W-&&c?G-j`3ZlJ~e|h z5F-xg>(xl0EU|Qk!$M*0--}#Q3!AVoFQHc zd5WxHMN@(z03u1OQHXT34kc6x;59#Q23|mgNjw@gJOoFB=8ReyH4Wq1?0e$kvFff} zkQfO4%Inl{b|C!55nq0fdU<)`Jq-L9Pm(9zr##Wtu4CUx=V~bH2HbLE^*$j zbKEZ@PnQ@Irf~BUz_zo$QY)wI_quuDM+DwT=!{57UI+%jp|&1KAR;SVMr$)ZgRsXy zr(o$Xx~*4DJVxeuGplA3KUU|OZQ#t>@W9mTne}5 zfeZZX!#ZKL{=uuTR|73M?A9*u4H9Io3;YlRpOMrF$0qO<0G6ovavxwM-vKgm2SAzu zAjlt(C;_rn=%E&u>9%Fw#MOv8xw1dP01H3zoEMS6ZrqgxHxA`_Sp;6b2JZoam3R!jXo_YFQWrPY4P}4X`Z@T%? zbX>M5+K@E2I_ZD0g85o;jSmSsXmW>7J1Wx=V28A=g z6+pSrH{D_-UwoDWX9Sx zvdjj&@2HhI=`h~NjN<@tJNr)SSnwkL(SH%oJv@rC*KZ5Q1$7mzrmluXTYIhj4)qH- zU~zc%^G6;>tL$ot`vOf(((eR!J8Jm*W=@6Trd%KpKD9(8%mk$9Ig{tLzu&iW=tSigFtR zXLzz1nrPoyj8r~bh_U%VqK;(~HahzwwaW;}MBR+W(+_+bL|GPQPIZe&Sz*pgQg7C? z=B%Q;HCnJG@>Szs44zr~Xk8`1K`b_*ltT3;=#LIB(({RP0OlL>x~sAhhE zaKQGM#yFD`5+glazb#l zVoso*?PUHFU^v_Xg3o6WYAi6#L9!~E`A9YCF@KM85l94t$F5$T3+CzCD$?=@+DS30$ tljO6KEReU(J84|Mos?vMMVW;&%9?hy*e-6iHa9l6Hk+Gk_;2IC`5%S?d^G?7 diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/__init__.cpython-37.pyc b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/__init__.cpython-37.pyc deleted file mode 100644 index 3160d4f718954ce6fa7773204c4406c2a04b69b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 311 zcmXv|!Ait15KYr{U3OXg1Mhoi5fKlvh^t~Ri-NM35*$J^wAC~TNh-T%kN$)nz56x2 z`WK!|iw?XuGrY&lyPVIbB-Gd2?C~b_?|%8OQ5Y@)++m;%K_bX&^d2F__=DyYN4O`V zB@y(2EaH6MJeRGnWj6sZ+*bg%i*Yvvs2iiL2gql*^{B+4+9=%Yt%^4Y(8bQ%?f`%* zr9JnxRu15k*m1B8^z(9c#x@SEV^6N)1zQ<&%{^ypU2w^=yDTkq!!j=UcE^lt%UU@W z;JK72SUCtutvr@?c#x>mljI(~)hk<6Nph4P|G8KQt?CdtHM?%IY_w=4p7)6z4MfQ= D9yMLS diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/reader.cpython-37.pyc b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__pycache__/reader.cpython-37.pyc deleted file mode 100644 index 9739dc32aedad237a3a2685bff5955a2a068355a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1057 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 diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/plugin.cpython-37.pyc b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/plugin.cpython-37.pyc deleted file mode 100644 index d5f1f7c7c6e98bf336400c9a11e523bd1cdfff47..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32048 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 diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/types.cpython-37.pyc b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__pycache__/types.cpython-37.pyc deleted file mode 100644 index ec0b787871b3d4f40b220bf76af30350bd627379..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6289 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 diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/corrections.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/__init__.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/consts.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/errors.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/jsonrpc.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/plugin.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/types.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/http.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/proc_tools.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/reader.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/registry_monitor.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/task_manager.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/tools.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/mock.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json new file mode 100644 index 0000000..9882b83 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy PS1 RetroArch plugin", + "platform": "psx", + "guid": "ff02c67d-5962-4e79-a3a3-928814edb270", + "version": "0.1", + "description": "Galaxy Plugin to add PS1 isos and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py new file mode 100644 index 0000000..45be19e --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py @@ -0,0 +1,141 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.PlayStation, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Sony - PlayStation.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + if entry["label"].split(" (")[0] in corrections.correction_list: + correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + else: + correct_name = entry["label"].split(" (")[0] + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/user_config.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/user_config.py new file mode 100644 index 0000000..2b53f51 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "pcsx_rearmed_libretro.dll" + +core = "" \ No newline at end of file diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py @@ -0,0 +1 @@ +__version__ = '0.1' diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/corrections.py b/psp_05487532-ba29-411b-b799-784262d275bd/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/__init__.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/consts.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/errors.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/jsonrpc.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/plugin.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/types.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/http.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/proc_tools.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/reader.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/registry_monitor.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/task_manager.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/tools.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/mock.py b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json b/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json new file mode 100644 index 0000000..1eb3361 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy PSP RetroArch plugin", + "platform": "psp", + "guid": "05487532-ba29-411b-b799-784262d275bd", + "version": "0.1", + "description": "Galaxy Plugin to add PSP isos and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py new file mode 100644 index 0000000..49f0b55 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py @@ -0,0 +1,141 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.PlayStationPortable, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Sony - PlayStation Portable.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + if entry["label"].split(" (")[0] in corrections.correction_list: + correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + else: + correct_name = entry["label"].split(" (")[0] + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/user_config.py b/psp_05487532-ba29-411b-b799-784262d275bd/user_config.py new file mode 100644 index 0000000..68ef7fc --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "ppsspp_libretro.dll" + +core = "" \ No newline at end of file diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/version.py b/psp_05487532-ba29-411b-b799-784262d275bd/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/psp_05487532-ba29-411b-b799-784262d275bd/version.py @@ -0,0 +1 @@ +__version__ = '0.1' diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/__init__.cpython-37.pyc b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/__init__.cpython-37.pyc deleted file mode 100644 index 3160d4f718954ce6fa7773204c4406c2a04b69b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 311 zcmXv|!Ait15KYr{U3OXg1Mhoi5fKlvh^t~Ri-NM35*$J^wAC~TNh-T%kN$)nz56x2 z`WK!|iw?XuGrY&lyPVIbB-Gd2?C~b_?|%8OQ5Y@)++m;%K_bX&^d2F__=DyYN4O`V zB@y(2EaH6MJeRGnWj6sZ+*bg%i*Yvvs2iiL2gql*^{B+4+9=%Yt%^4Y(8bQ%?f`%* zr9JnxRu15k*m1B8^z(9c#x@SEV^6N)1zQ<&%{^ypU2w^=yDTkq!!j=UcE^lt%UU@W z;JK72SUCtutvr@?c#x>mljI(~)hk<6Nph4P|G8KQt?CdtHM?%IY_w=4p7)6z4MfQ= D9yMLS diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/reader.cpython-37.pyc b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__pycache__/reader.cpython-37.pyc deleted file mode 100644 index 9739dc32aedad237a3a2685bff5955a2a068355a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1057 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 diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/plugin.cpython-37.pyc b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/plugin.cpython-37.pyc deleted file mode 100644 index d5f1f7c7c6e98bf336400c9a11e523bd1cdfff47..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32048 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 diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/types.cpython-37.pyc b/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__pycache__/types.cpython-37.pyc deleted file mode 100644 index ec0b787871b3d4f40b220bf76af30350bd627379..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6289 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 From 3d3f0ff003e3881de00a5046a51309a3084ff4f1 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Wed, 1 Jan 2020 18:04:51 -0800 Subject: [PATCH 05/37] FIX: bug with psx and psp --- ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py | 2 +- psp_05487532-ba29-411b-b799-784262d275bd/plugin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py index 45be19e..53dbda6 100644 --- a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py @@ -111,7 +111,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == rom["label"].split(" (")[0]: file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py index 49f0b55..a9719ff 100644 --- a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py +++ b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py @@ -111,7 +111,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == rom["label"].split(" (")[0]: file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: From 2b956d4514c86e4429ea7410693100194f84614f Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Wed, 1 Jan 2020 18:57:39 -0800 Subject: [PATCH 06/37] ADD: sms and segag --- README.md | 2 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 141 +++ .../user_config.py | 12 + .../version.py | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 141 +++ .../user_config.py | 12 + .../version.py | 1 + 37 files changed, 4186 insertions(+) create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/__init__.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/consts.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/errors.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/jsonrpc.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/plugin.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/types.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/http.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/proc_tools.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/reader.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/registry_monitor.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/task_manager.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/tools.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/mock.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/user_config.py create mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/corrections.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/__init__.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/consts.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/errors.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/jsonrpc.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/plugin.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/types.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/http.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/proc_tools.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/reader.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/registry_monitor.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/task_manager.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/tools.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/mock.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/user_config.py create mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py diff --git a/README.md b/README.md index aede59e..b1bbc65 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - Nintendo Entertainment System - Super Nintendo Entertainment System - Nintendo 64 (Riku55) +- SEGA Master System +- SEGA Genesis / Mega Drive - PlayStation - PlayStation Portable diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/__init__.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/consts.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/errors.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/jsonrpc.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/plugin.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/types.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/http.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/proc_tools.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/reader.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/registry_monitor.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/task_manager.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/tools.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/mock.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json new file mode 100644 index 0000000..a61d5e9 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Sega Genesis RetroArch plugin", + "platform": "segag", + "guid": "e3ac94e7-945e-459d-bc1e-676cff8173f9", + "version": "0.1", + "description": "Galaxy Plugin to add Sega Genesis roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py new file mode 100644 index 0000000..0fbfe49 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py @@ -0,0 +1,141 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.SegaGenesis, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Sega - Mega Drive - Genesis.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + if entry["label"].split(" (")[0] in corrections.correction_list: + correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + else: + correct_name = entry["label"].split(" (")[0] + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/user_config.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/user_config.py new file mode 100644 index 0000000..5de008e --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "genesis_plus_gx_libretro.dll" + +core = "" \ No newline at end of file diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py @@ -0,0 +1 @@ +__version__ = '0.1' diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/corrections.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/__init__.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/consts.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/errors.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/jsonrpc.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/plugin.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/types.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/http.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/proc_tools.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/reader.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/registry_monitor.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/task_manager.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/tools.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/mock.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json new file mode 100644 index 0000000..a47b7cc --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Sega Master System RetroArch plugin", + "platform": "sms", + "guid": "c6689bfb-7ba4-4d24-98e3-bd2dc339926b", + "version": "0.1", + "description": "Galaxy Plugin to add Sega Master System roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py new file mode 100644 index 0000000..8506f9b --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py @@ -0,0 +1,141 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.SegaMasterSystem, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Sega - Master System - Mark III.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + if entry["label"].split(" (")[0] in corrections.correction_list: + correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + else: + correct_name = entry["label"].split(" (")[0] + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/user_config.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/user_config.py new file mode 100644 index 0000000..5de008e --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "genesis_plus_gx_libretro.dll" + +core = "" \ No newline at end of file diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From 7bfdb2597f845395f10e1d4e736ecfecc09c3812 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Sat, 4 Jan 2020 10:24:10 -0800 Subject: [PATCH 07/37] UPD: standardize naming Also prep achievement types for future update --- .../plugin.py | 21 ++++++++++++------- .../plugin.py | 21 ++++++++++++------- .../plugin.py | 13 ++++++++---- .../plugin.py | 13 ++++++++---- .../corrections.py | 8 ++++++- .../plugin.py | 13 ++++++++---- .../plugin.py | 13 ++++++++---- .../plugin.py | 21 ++++++++++++------- 8 files changed, 82 insertions(+), 41 deletions(-) diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py index 96b0047..0844034 100644 --- a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py @@ -4,9 +4,13 @@ import json, urllib.request, os, os.path import user_config, corrections import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional from galaxy.api.consts import LicenseType, LocalGameState, Platform from galaxy.api.plugin import Plugin, create_and_run_plugin -from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime from version import __version__ as version @@ -46,13 +50,14 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): - if entry["label"].split(" (")[0] in corrections.correction_list: - correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] else: - correct_name = entry["label"].split(" (")[0] + correct_name = provided_name game_list.append( Game( - entry["crc32"].split("|")[0], + correct_name, correct_name, None, LicenseInfo(LicenseType.SinglePurchase, None) @@ -96,9 +101,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["crc32"].split("|")[0]: + if game_id == entry["label"].split(" (")[0]: self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["crc32"].split("|")[0] + self.game_run = entry["label"].split(" (")[0] self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -112,7 +117,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["crc32"].split("|")[0]: + if game_id == rom["label"].split(" (")[0]: file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py index 5cb09a4..b2c2725 100644 --- a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py @@ -4,9 +4,13 @@ import json, urllib.request, os, os.path import user_config, corrections import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional from galaxy.api.consts import LicenseType, LocalGameState, Platform from galaxy.api.plugin import Plugin, create_and_run_plugin -from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime from version import __version__ as version @@ -45,13 +49,14 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): - if entry["label"].split(" (")[0] in corrections.correction_list: - correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] else: - correct_name = entry["label"].split(" (")[0] + correct_name = provided_name game_list.append( Game( - entry["crc32"].split("|")[0], + correct_name, correct_name, None, LicenseInfo(LicenseType.SinglePurchase, None) @@ -95,9 +100,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["crc32"].split("|")[0]: + if game_id == entry["label"].split(" (")[0]: self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["crc32"].split("|")[0] + self.game_run = entry["label"].split(" (")[0] self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -111,7 +116,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["crc32"].split("|")[0]: + if game_id == rom["label"].split(" (")[0]: file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py index 53dbda6..cefd85c 100644 --- a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py @@ -4,9 +4,13 @@ import json, urllib.request, os, os.path import user_config, corrections import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional from galaxy.api.consts import LicenseType, LocalGameState, Platform from galaxy.api.plugin import Plugin, create_and_run_plugin -from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime from version import __version__ as version @@ -45,10 +49,11 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): - if entry["label"].split(" (")[0] in corrections.correction_list: - correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] else: - correct_name = entry["label"].split(" (")[0] + correct_name = provided_name game_list.append( Game( correct_name, diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py index a9719ff..ff078fe 100644 --- a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py +++ b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py @@ -4,9 +4,13 @@ import json, urllib.request, os, os.path import user_config, corrections import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional from galaxy.api.consts import LicenseType, LocalGameState, Platform from galaxy.api.plugin import Plugin, create_and_run_plugin -from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime from version import __version__ as version @@ -45,10 +49,11 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): - if entry["label"].split(" (")[0] in corrections.correction_list: - correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] else: - correct_name = entry["label"].split(" (")[0] + correct_name = provided_name game_list.append( Game( correct_name, diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py index 401ed9e..d005d29 100644 --- a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py @@ -1 +1,7 @@ -correction_list = {} \ No newline at end of file +correction_list = {} + +correction_list["Sonic Spinball"] = "Sonic the Hedgehog: Spinball" +correction_list["Adventures of Batman & Robin, The"] = "The Adventures of Batman & Robin (Genesis)" +correction_list["Alien 3"] = "Alien\u00b3" +correction_list["Batman - The Video Game"] = "Batman" +correction_list["Chakan"] = "Chakan: The Forever Man" \ No newline at end of file diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py index 0fbfe49..d01d372 100644 --- a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py @@ -4,9 +4,13 @@ import json, urllib.request, os, os.path import user_config, corrections import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional from galaxy.api.consts import LicenseType, LocalGameState, Platform from galaxy.api.plugin import Plugin, create_and_run_plugin -from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime from version import __version__ as version @@ -45,10 +49,11 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): - if entry["label"].split(" (")[0] in corrections.correction_list: - correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] else: - correct_name = entry["label"].split(" (")[0] + correct_name = provided_name game_list.append( Game( correct_name, diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py index 8506f9b..b3f7e95 100644 --- a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py @@ -4,9 +4,13 @@ import json, urllib.request, os, os.path import user_config, corrections import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional from galaxy.api.consts import LicenseType, LocalGameState, Platform from galaxy.api.plugin import Plugin, create_and_run_plugin -from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime from version import __version__ as version @@ -45,10 +49,11 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): - if entry["label"].split(" (")[0] in corrections.correction_list: - correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] else: - correct_name = entry["label"].split(" (")[0] + correct_name = provided_name game_list.append( Game( correct_name, diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py index 2f30d29..c6a0ece 100644 --- a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py @@ -4,9 +4,13 @@ import json, urllib.request, os, os.path import user_config, corrections import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional from galaxy.api.consts import LicenseType, LocalGameState, Platform from galaxy.api.plugin import Plugin, create_and_run_plugin -from galaxy.api.types import Authentication, Game, LicenseInfo, LocalGame, GameTime +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime from version import __version__ as version @@ -45,13 +49,14 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): - if entry["label"].split(" (")[0] in corrections.correction_list: - correct_name = corrections.correction_list[entry["label"].split(" (")[0]] + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] else: - correct_name = entry["label"].split(" (")[0] + correct_name = provided_name game_list.append( Game( - entry["crc32"].split("|")[0], + correct_name, correct_name, None, LicenseInfo(LicenseType.SinglePurchase, None) @@ -95,9 +100,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["crc32"].split("|")[0]: + if game_id == ["label"].split(" (")[0]: self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["crc32"].split("|")[0] + self.game_run = ["label"].split(" (")[0] self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -111,7 +116,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["crc32"].split("|")[0]: + if game_id == rom["label"].split(" (")[0]: file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: From 8a5b8492cc4ddbfef173d290ac58fb587125e427 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Sun, 5 Jan 2020 17:57:31 -0800 Subject: [PATCH 08/37] ADD: Sega CD and Sega Saturn --- .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + 36 files changed, 4194 insertions(+) create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/corrections.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/__init__.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/consts.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/errors.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/jsonrpc.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/plugin.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/types.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/http.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/proc_tools.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/reader.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/registry_monitor.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/task_manager.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/tools.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/mock.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/user_config.py create mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/corrections.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/__init__.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/consts.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/errors.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/jsonrpc.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/plugin.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/types.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/http.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/proc_tools.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/reader.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/registry_monitor.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/task_manager.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/tools.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/mock.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/user_config.py create mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/corrections.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/__init__.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/consts.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/errors.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/jsonrpc.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/plugin.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/types.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/http.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/proc_tools.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/reader.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/registry_monitor.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/task_manager.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/tools.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/mock.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json new file mode 100644 index 0000000..b662c2a --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Sega Saturn RetroArch plugin", + "platform": "saturn", + "guid": "bd6ec091-8ee0-440a-9e26-71bbf21c05af", + "version": "0.1", + "description": "Galaxy Plugin to add Sega Saturn isos and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py new file mode 100644 index 0000000..71323e5 --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.SegaSaturn, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Sega - Saturn.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/user_config.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/user_config.py new file mode 100644 index 0000000..771e35b --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "mednafen_saturn_libretro.dll" + +core = "" \ No newline at end of file diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py @@ -0,0 +1 @@ +__version__ = '0.1' diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/corrections.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/__init__.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/consts.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/errors.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/jsonrpc.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/plugin.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/types.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/http.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/proc_tools.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/reader.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/registry_monitor.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/task_manager.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/tools.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/mock.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json new file mode 100644 index 0000000..aa70ef7 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Sega CD RetroArch plugin", + "platform": "segacd", + "guid": "ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2", + "version": "0.1", + "description": "Galaxy Plugin to add Sega CD isos and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py new file mode 100644 index 0000000..0492952 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.SegaCd, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Sega - Mega-CD - Sega CD.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/user_config.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/user_config.py new file mode 100644 index 0000000..5de008e --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "genesis_plus_gx_libretro.dll" + +core = "" \ No newline at end of file diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From fa723a877f9fe7b04609003be0d7b4ea8949b07a Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Sun, 5 Jan 2020 18:08:27 -0800 Subject: [PATCH 09/37] UPDATE: readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b1bbc65..37df677 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - Nintendo 64 (Riku55) - SEGA Master System - SEGA Genesis / Mega Drive +- SEGA CD +- SEGA Saturn - PlayStation - PlayStation Portable From e14066c7bc17b426d5413960e828e42370f92e1a Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Sat, 11 Jan 2020 16:54:48 -0800 Subject: [PATCH 10/37] ADD: SEGA Dreamcast --- README.md | 2 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + 19 files changed, 2099 insertions(+) create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/corrections.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/__init__.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/consts.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/errors.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/jsonrpc.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/plugin.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/types.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/http.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/proc_tools.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/reader.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/registry_monitor.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/task_manager.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/tools.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/mock.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/user_config.py create mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py diff --git a/README.md b/README.md index 37df677..4f6a148 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ The goal of this project is to integrate all retro platforms that are supported This project is currently a work in progress. Bugs may be present. Created with the Galaxy API: https://github.com/gogcom/galaxy-integrations-python-api + Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-integration-n64-RetroArch- #### Working Features: @@ -26,6 +27,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - SEGA Genesis / Mega Drive - SEGA CD - SEGA Saturn +- SEGA Dreamcast - PlayStation - PlayStation Portable diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/corrections.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/__init__.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/consts.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/errors.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/jsonrpc.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/plugin.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/types.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/http.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/proc_tools.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/reader.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/registry_monitor.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/task_manager.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/tools.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/mock.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json new file mode 100644 index 0000000..e2a3c11 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Sega Dreamcast RetroArch plugin", + "platform": "dc", + "guid": "5d181ffd-48dc-4330-aa58-6f646e76a5c8", + "version": "0.1", + "description": "Galaxy Plugin to add Sega Dreamcast isos and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py new file mode 100644 index 0000000..91302b4 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.SegaDreamcast, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Sega - Dreamcast.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/user_config.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/user_config.py new file mode 100644 index 0000000..5980f35 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "flycast_libretro.dll" + +core = "" \ No newline at end of file diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From fe6bf3f67adcaec5bb5d519e086fb7e990cd8a24 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Thu, 16 Jan 2020 17:49:12 -0800 Subject: [PATCH 11/37] FIX: Bug in SNES Thanks Drakal for the heads up! --- snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py index c6a0ece..8cb9746 100644 --- a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py @@ -100,9 +100,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == ["label"].split(" (")[0]: + if game_id == entry["label"].split(" (")[0]: self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = ["label"].split(" (")[0] + self.game_run = entry["label"].split(" (")[0] self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break From 083b82a514da5d48f9b49d2020a9ea148b1692e7 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Fri, 24 Jan 2020 14:05:44 -0800 Subject: [PATCH 12/37] ADD: Atari 2600 --- README.md | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + 19 files changed, 2098 insertions(+) create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/corrections.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/__init__.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/consts.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/errors.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/jsonrpc.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/plugin.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/types.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/http.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/proc_tools.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/reader.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/registry_monitor.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/task_manager.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/tools.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/mock.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/user_config.py create mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py diff --git a/README.md b/README.md index 4f6a148..d5c4ec8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in ## Currently Supported Platforms +- Atari 2600 - Nintendo Entertainment System - Super Nintendo Entertainment System - Nintendo 64 (Riku55) diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/corrections.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/__init__.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/consts.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/errors.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/jsonrpc.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/plugin.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/types.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/http.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/proc_tools.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/reader.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/registry_monitor.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/task_manager.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/tools.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/mock.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json new file mode 100644 index 0000000..ce98c9f --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Atari 2600 RetroArch plugin", + "platform": "atari", + "guid": "830528d9-e621-48e9-8ed4-e03a4853843e", + "version": "0.1", + "description": "Galaxy Plugin to add Atari 2600 roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py new file mode 100644 index 0000000..7f30b28 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.Atari, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Atari - 2600.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/user_config.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/user_config.py new file mode 100644 index 0000000..9b90785 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "stella_libretro.dll" + +core = "" \ No newline at end of file diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From 95d8d7c554327ffae7d137d0957525fff514bd56 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Fri, 24 Jan 2020 14:15:31 -0800 Subject: [PATCH 13/37] ADD: Atari Jaguar --- README.md | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + 19 files changed, 2098 insertions(+) create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/corrections.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/__init__.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/consts.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/errors.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/jsonrpc.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/plugin.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/types.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/http.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/proc_tools.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/reader.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/registry_monitor.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/task_manager.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/tools.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/mock.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/user_config.py create mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py diff --git a/README.md b/README.md index d5c4ec8..51a5ae8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in ## Currently Supported Platforms - Atari 2600 +- Atari Jaguar - Nintendo Entertainment System - Super Nintendo Entertainment System - Nintendo 64 (Riku55) diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/corrections.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/__init__.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/consts.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/errors.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/jsonrpc.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/plugin.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/types.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/http.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/proc_tools.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/reader.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/registry_monitor.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/task_manager.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/tools.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/mock.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json new file mode 100644 index 0000000..f583814 --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Atari Jaguar RetroArch plugin", + "platform": "jaguar", + "guid": "b9773549-9c20-4729-b23d-f683762ce73a", + "version": "0.1", + "description": "Galaxy Plugin to add Atari Jaguar roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py new file mode 100644 index 0000000..97c4d6c --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.AtariJaguar, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Atari - Jaguar.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/user_config.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/user_config.py new file mode 100644 index 0000000..b17325b --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "virtualjaguar_libretro.dll" + +core = "" \ No newline at end of file diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From ebc04999d98da6e160406dc2d058ab19652667dc Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Fri, 24 Jan 2020 15:15:00 -0800 Subject: [PATCH 14/37] ADD: Nintendo GameCube --- README.md | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + 19 files changed, 2098 insertions(+) create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/corrections.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/__init__.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/consts.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/errors.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/jsonrpc.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/plugin.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/types.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/http.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/proc_tools.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/reader.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/registry_monitor.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/task_manager.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/tools.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/mock.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/user_config.py create mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py diff --git a/README.md b/README.md index 51a5ae8..7e7e4e8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - Nintendo Entertainment System - Super Nintendo Entertainment System - Nintendo 64 (Riku55) +- Nintendo GameCube - SEGA Master System - SEGA Genesis / Mega Drive - SEGA CD diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/corrections.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/__init__.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/consts.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/errors.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/jsonrpc.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/plugin.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/types.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/http.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/proc_tools.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/reader.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/registry_monitor.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/task_manager.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/tools.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/mock.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json new file mode 100644 index 0000000..a221414 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Nintendo Gamecube RetroArch plugin", + "platform": "ncube", + "guid": "602422b9-ced5-476e-911a-7fa0adf0f7f7", + "version": "0.1", + "description": "Galaxy Plugin to add Nintendo Gamecube isos and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py new file mode 100644 index 0000000..ce9c558 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.NintendoGameCube, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Nintendo - GameCube.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/user_config.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/user_config.py new file mode 100644 index 0000000..d7b4bc3 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "dolphin_libretro.dll" + +core = "" \ No newline at end of file diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From 8129bcdd032b544b068a49d036f3c252935a7b2a Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Fri, 24 Jan 2020 15:34:59 -0800 Subject: [PATCH 15/37] ADD: Nintendo Wii --- README.md | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + 19 files changed, 2098 insertions(+) create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/corrections.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/__init__.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/consts.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/errors.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/jsonrpc.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/plugin.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/types.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/http.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/proc_tools.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/reader.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/registry_monitor.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/task_manager.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/tools.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/mock.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/user_config.py create mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py diff --git a/README.md b/README.md index 7e7e4e8..1401f0b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - Super Nintendo Entertainment System - Nintendo 64 (Riku55) - Nintendo GameCube +- Nintendo Wii - SEGA Master System - SEGA Genesis / Mega Drive - SEGA CD diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/corrections.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/__init__.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/consts.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/errors.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/jsonrpc.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/plugin.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/types.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/http.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/proc_tools.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/reader.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/registry_monitor.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/task_manager.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/tools.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/mock.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json new file mode 100644 index 0000000..026048d --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Nintendo Wii RetroArch plugin", + "platform": "nwii", + "guid": "2d0e97ac-0406-4e5f-a85b-ab5b1a042cba", + "version": "0.1", + "description": "Galaxy Plugin to add Nintendo Wii isos and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py new file mode 100644 index 0000000..099b2f2 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.NintendoWii, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Nintendo - Wii.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/user_config.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/user_config.py new file mode 100644 index 0000000..d7b4bc3 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "dolphin_libretro.dll" + +core = "" \ No newline at end of file diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From d80f88b3ef8dc011317e1b2041eafd32c61256d9 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Sun, 16 Feb 2020 17:48:55 -0800 Subject: [PATCH 16/37] ADD: GB, GBC, GBA Sadly, these all currently share the same platform ID which means only one can be used at at a time. Hopefully https://github.com/gogcom/galaxy-integrations-python-api/pull/71 gets resolved to correct this. --- README.md | 3 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + 55 files changed, 6294 insertions(+) create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/corrections.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/__init__.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/consts.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/errors.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/jsonrpc.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/plugin.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/types.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/http.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/proc_tools.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/reader.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/registry_monitor.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/task_manager.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/tools.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/mock.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/user_config.py create mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/corrections.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/__init__.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/consts.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/errors.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/jsonrpc.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/plugin.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/types.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/http.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/proc_tools.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/reader.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/registry_monitor.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/task_manager.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/tools.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/mock.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/user_config.py create mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/corrections.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/__init__.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/consts.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/errors.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/jsonrpc.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/plugin.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/types.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/http.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/proc_tools.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/reader.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/registry_monitor.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/task_manager.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/tools.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/mock.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/user_config.py create mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py diff --git a/README.md b/README.md index 1401f0b..cb27fc4 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - Nintendo 64 (Riku55) - Nintendo GameCube - Nintendo Wii +- Nintendo Game Boy +- Nintendo Game Boy Color +- Nintendo Game Boy Advance - SEGA Master System - SEGA Genesis / Mega Drive - SEGA CD diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/corrections.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/__init__.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/consts.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/errors.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/jsonrpc.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/plugin.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/types.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/http.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/proc_tools.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/reader.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/registry_monitor.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/task_manager.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/tools.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/mock.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json new file mode 100644 index 0000000..e995295 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Nintendo Gameboy RetroArch plugin", + "platform": "ngameboy", + "guid": "4345afe1-a2c3-4c58-93d3-373c53a90a92", + "version": "0.1", + "description": "Galaxy Plugin to add Nintendo GameBoy roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py new file mode 100644 index 0000000..90e3b11 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.NintendoGameBoy, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Nintendo - Game Boy.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/user_config.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/user_config.py new file mode 100644 index 0000000..634b179 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "mgba_libretro.dll" + +core = "" \ No newline at end of file diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py @@ -0,0 +1 @@ +__version__ = '0.1' diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/corrections.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/__init__.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/consts.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/errors.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/jsonrpc.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/plugin.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/types.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/http.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/proc_tools.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/reader.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/registry_monitor.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/task_manager.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/tools.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/mock.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json new file mode 100644 index 0000000..f106180 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Nintendo Gameboy Advance RetroArch plugin", + "platform": "ngameboy", + "guid": "16a78ef5-fba6-4629-b83c-ef47adab5aab", + "version": "0.1", + "description": "Galaxy Plugin to add Nintendo GameBoy Advance roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py new file mode 100644 index 0000000..8950566 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.NintendoGameBoy, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Nintendo - Game Boy Advance.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/user_config.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/user_config.py new file mode 100644 index 0000000..634b179 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "mgba_libretro.dll" + +core = "" \ No newline at end of file diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py @@ -0,0 +1 @@ +__version__ = '0.1' diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/corrections.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/__init__.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/consts.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/errors.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/jsonrpc.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/plugin.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/types.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/http.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/proc_tools.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/reader.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/registry_monitor.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/task_manager.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/tools.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/mock.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json new file mode 100644 index 0000000..d0f31fd --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Nintendo Gameboy Color RetroArch plugin", + "platform": "ngameboy", + "guid": "9b53fc85-af7c-4ce2-af31-0d95234d783a", + "version": "0.1", + "description": "Galaxy Plugin to add Nintendo GameBoy Color roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py new file mode 100644 index 0000000..2c8f500 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.NintendoGameBoy, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Nintendo - Game Boy Color.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/user_config.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/user_config.py new file mode 100644 index 0000000..634b179 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "mgba_libretro.dll" + +core = "" \ No newline at end of file diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From b20a0231a1c101e7866676fd7cf542da4824b8e7 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Sat, 21 Mar 2020 11:52:43 -0700 Subject: [PATCH 17/37] ADD: DS and 3DS --- .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + README.md | 2 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 146 ++++ .../user_config.py | 12 + .../version.py | 1 + 37 files changed, 4196 insertions(+) create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/corrections.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/__init__.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/consts.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/errors.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/jsonrpc.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/plugin.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/types.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/http.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/proc_tools.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/reader.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/registry_monitor.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/task_manager.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/tools.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/mock.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/user_config.py create mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/corrections.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/__init__.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/consts.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/errors.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/jsonrpc.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/plugin.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/types.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/http.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/proc_tools.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/reader.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/registry_monitor.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/task_manager.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/tools.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/mock.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/user_config.py create mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/corrections.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/__init__.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/consts.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/errors.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/jsonrpc.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/plugin.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/types.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/http.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/proc_tools.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/reader.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/registry_monitor.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/task_manager.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/tools.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/mock.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json new file mode 100644 index 0000000..28354df --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Nintendo 3DS RetroArch plugin", + "platform": "3ds", + "guid": "f6acd3ed-2c31-47d6-bae4-07b6714c1e55", + "version": "0.1", + "description": "Galaxy Plugin to add Nintendo 3DS roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py new file mode 100644 index 0000000..093d431 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.Nintendo3Ds, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Nintendo - Nintendo 3DS.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/user_config.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/user_config.py new file mode 100644 index 0000000..16ad265 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "citra_libretro.dll" + +core = "" \ No newline at end of file diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py @@ -0,0 +1 @@ +__version__ = '0.1' diff --git a/README.md b/README.md index cb27fc4..9ed6170 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - Nintendo Game Boy - Nintendo Game Boy Color - Nintendo Game Boy Advance +- Nintendo DS +- Nintendo 3DS - SEGA Master System - SEGA Genesis / Mega Drive - SEGA CD diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/corrections.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/__init__.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/consts.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/errors.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/jsonrpc.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/plugin.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/types.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/http.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/proc_tools.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/reader.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/registry_monitor.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/task_manager.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/tools.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/mock.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json new file mode 100644 index 0000000..9b5d479 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy Nintendo DS RetroArch plugin", + "platform": "nds", + "guid": "4704ed29-f516-4fd8-8477-ddbcdb7cedfc", + "version": "0.1", + "description": "Galaxy Plugin to add Nintendo DS roms and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py new file mode 100644 index 0000000..83de1d9 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py @@ -0,0 +1,146 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.NintendoDs, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Nintendo - Nintendo DS.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/user_config.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/user_config.py new file mode 100644 index 0000000..6c45117 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "desmume_libretro.dll" + +core = "" \ No newline at end of file diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From c5a712b998b3e0e532f04d31c338f958001844a7 Mon Sep 17 00:00:00 2001 From: ALLION Benjamin Date: Sun, 14 Jun 2020 00:07:29 +0200 Subject: [PATCH 18/37] fix(plugin): add archive file support for each platform. Co-authored-by: ChristofferGreen Co-authored-by: benjamin-allion --- .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py | 13 +++++++------ gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 15 ++++++++------- .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ .../plugin.py | 13 +++++++------ 19 files changed, 134 insertions(+), 115 deletions(-) diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py index 093d431..42614c3 100644 --- a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py index 7f30b28..2813684 100644 --- a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py index 91302b4..87a8792 100644 --- a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py index 90e3b11..c11299b 100644 --- a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py index 8950566..28ea07f 100644 --- a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py index 2c8f500..b78ba04 100644 --- a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py index 97c4d6c..781e9e4 100644 --- a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py index 0844034..88e9782 100644 --- a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py @@ -49,11 +49,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -95,7 +96,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -136,13 +137,13 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() - + def main(): create_and_run_plugin(Retroarch, sys.argv) - + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py index ce9c558..b9250df 100644 --- a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py index 83de1d9..7d8f24d 100644 --- a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py index b2c2725..09bd98f 100644 --- a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py index 099b2f2..406cd3d 100644 --- a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py index cefd85c..00cd3e8 100644 --- a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py index ff078fe..fb1439d 100644 --- a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py +++ b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py index 71323e5..a497b3e 100644 --- a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py index 0492952..8e0de93 100644 --- a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py index d01d372..00ba17c 100644 --- a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py index b3f7e95..5d55e2c 100644 --- a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py index 8cb9746..04311f4 100644 --- a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py @@ -48,11 +48,12 @@ def update_game_cache(self): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if os.path.abspath(user_config.rom_path) in os.path.abspath(entry["path"]) and os.path.isfile(entry["path"]): + rom_path = entry["path"].split("#")[0] +if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] - else: + else: correct_name = provided_name game_list.append( Game( @@ -72,7 +73,7 @@ def update_game_cache(self): #removes games when removed while running for entry in self.game_cache: if entry not in game_list: - self.game_cache.remove(entry) + self.game_cache.remove(entry) self.remove_game(entry.game_id) #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed @@ -94,7 +95,7 @@ async def uninstall_game(self, game_id): def shutdown(self): pass - #potentially give user more customization possibilities like starting in fullscreen etc + #potentially give user more customization possibilities like starting in fullscreen etc async def launch_game(self, game_id): if os.path.isfile(self.playlist_path): with open(self.playlist_path) as playlist_json: @@ -135,7 +136,7 @@ def tick(self): self.proc = None except AttributeError: pass - + self.update_game_cache() self.get_local_games() @@ -143,4 +144,4 @@ def main(): create_and_run_plugin(Retroarch, sys.argv) if __name__ == "__main__": - main() \ No newline at end of file + main() From 6f88f52d4cf7a5c35e9f9db02ec544af7999bb53 Mon Sep 17 00:00:00 2001 From: ALLION Benjamin Date: Sun, 14 Jun 2020 00:11:40 +0200 Subject: [PATCH 19/37] refactor(plugin): fix indentation problem. --- 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py | 2 +- atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py | 2 +- dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py | 2 +- gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py | 2 +- gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py | 2 +- gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py | 2 +- jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py | 2 +- n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py | 2 +- ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py | 2 +- nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py | 2 +- nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py | 2 +- nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py | 2 +- ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py | 2 +- psp_05487532-ba29-411b-b799-784262d275bd/plugin.py | 2 +- saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py | 2 +- segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py | 2 +- segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py | 2 +- sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py | 2 +- snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py index 42614c3..28b33b9 100644 --- a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py index 2813684..af5955f 100644 --- a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py index 87a8792..b05c83b 100644 --- a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py index c11299b..b7909e8 100644 --- a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py index 28ea07f..0283202 100644 --- a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py index b78ba04..ee0a705 100644 --- a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py index 781e9e4..a2f91a7 100644 --- a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py index 88e9782..5e36955 100644 --- a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py @@ -50,7 +50,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py index b9250df..756b412 100644 --- a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py index 7d8f24d..fc7a6b7 100644 --- a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py index 09bd98f..b9f6d48 100644 --- a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py index 406cd3d..1e609d6 100644 --- a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py index 00cd3e8..7a3130a 100644 --- a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py index fb1439d..70ab148 100644 --- a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py +++ b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py index a497b3e..a6830de 100644 --- a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py index 8e0de93..6b485e5 100644 --- a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py index 00ba17c..5d01f9e 100644 --- a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py index 5d55e2c..9be919d 100644 --- a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py index 04311f4..dc18fcd 100644 --- a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] -if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] if provided_name in corrections.correction_list: correct_name = corrections.correction_list[provided_name] From 17c193a961fa56046b448ea0e8be35f1e00225bc Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Sun, 23 Aug 2020 13:38:49 -0700 Subject: [PATCH 20/37] ADD: PlayStation 2 support --- README.md | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 147 ++++ .../user_config.py | 12 + .../version.py | 1 + 19 files changed, 2099 insertions(+) create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/corrections.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/__init__.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/consts.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/errors.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/jsonrpc.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/plugin.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/types.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/http.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/proc_tools.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/reader.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/registry_monitor.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/task_manager.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/tools.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/mock.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py create mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py diff --git a/README.md b/README.md index 9ed6170..d6de9ac 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - SEGA Saturn - SEGA Dreamcast - PlayStation +- PlayStation 2 - PlayStation Portable ![screenshot](https://imgur.com/A1Zk5Zt.png "Screenshot") diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/corrections.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/__init__.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/consts.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/errors.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/jsonrpc.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/plugin.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/types.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/http.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/proc_tools.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/reader.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/registry_monitor.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/task_manager.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/tools.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/mock.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json new file mode 100644 index 0000000..b7528e6 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy PS2 RetroArch plugin", + "platform": "ps2", + "guid": "50ad79eb-393c-4f95-98ce-59f095ae47ea", + "version": "0.1", + "description": "Galaxy Plugin to add PS2 isos and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py new file mode 100644 index 0000000..f5547d9 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py @@ -0,0 +1,147 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.PlayStation2, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/Sony - PlayStation 2.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + rom_path = entry["path"].split("#")[0] + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py new file mode 100644 index 0000000..2b53f51 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "pcsx_rearmed_libretro.dll" + +core = "" \ No newline at end of file diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From a13a526632676b06e2d889430750af4a5474df68 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Sun, 23 Aug 2020 15:51:41 -0700 Subject: [PATCH 21/37] ADD: 3DO Interactive --- .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 147 ++++ .../user_config.py | 12 + .../version.py | 1 + README.md | 1 + 19 files changed, 2099 insertions(+) create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/corrections.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/__init__.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/consts.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/errors.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/jsonrpc.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/plugin.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/types.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/http.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/proc_tools.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/reader.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/registry_monitor.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/task_manager.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/tools.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/mock.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py create mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/corrections.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/__init__.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/consts.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/errors.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/jsonrpc.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/plugin.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/types.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/http.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/proc_tools.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/reader.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/registry_monitor.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/task_manager.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/tools.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/mock.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json new file mode 100644 index 0000000..be16471 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy 3DO RetroArch plugin", + "platform": "3do", + "guid": "9d81c0ec-5646-4b1a-b809-e7e61e1d3577", + "version": "0.1", + "description": "Galaxy Plugin to add 3DO games and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py new file mode 100644 index 0000000..4f7188c --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py @@ -0,0 +1,147 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform._3Do, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/The 3DO Company - 3DO.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + rom_path = entry["path"].split("#")[0] + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py new file mode 100644 index 0000000..2b53f51 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "pcsx_rearmed_libretro.dll" + +core = "" \ No newline at end of file diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py @@ -0,0 +1 @@ +__version__ = '0.1' diff --git a/README.md b/README.md index d6de9ac..52824c3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in ## Currently Supported Platforms +- 3DO Interactive - Atari 2600 - Atari Jaguar - Nintendo Entertainment System From ea1b5dacdda0a538cbd183298e3d040166945425 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Sun, 23 Aug 2020 16:25:20 -0700 Subject: [PATCH 22/37] ADD: PC Engine --- README.md | 1 + .../corrections.py | 1 + .../galaxy/__init__.py | 1 + .../galaxy/api/consts.py | 130 +++ .../galaxy/api/errors.py | 75 ++ .../galaxy/api/jsonrpc.py | 299 +++++++ .../galaxy/api/plugin.py | 805 ++++++++++++++++++ .../galaxy/api/types.py | 153 ++++ .../galaxy/http.py | 144 ++++ .../galaxy/proc_tools.py | 88 ++ .../galaxy/reader.py | 28 + .../galaxy/registry_monitor.py | 98 +++ .../galaxy/task_manager.py | 52 ++ .../galaxy/tools.py | 22 + .../galaxy/unittest/mock.py | 31 + .../manifest.json | 11 + .../plugin.py | 147 ++++ .../user_config.py | 12 + .../version.py | 1 + 19 files changed, 2099 insertions(+) create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/corrections.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/__init__.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/consts.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/errors.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/jsonrpc.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/plugin.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/types.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/http.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/proc_tools.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/reader.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/registry_monitor.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/task_manager.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/tools.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/mock.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/user_config.py create mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py diff --git a/README.md b/README.md index 52824c3..2aace3e 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in - SEGA CD - SEGA Saturn - SEGA Dreamcast +- PC Engine / Turobgrafx 16 - PlayStation - PlayStation 2 - PlayStation Portable diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/corrections.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/corrections.py new file mode 100644 index 0000000..401ed9e --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/corrections.py @@ -0,0 +1 @@ +correction_list = {} \ No newline at end of file diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/__init__.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/__init__.py new file mode 100644 index 0000000..97b69ed --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/__init__.py @@ -0,0 +1 @@ +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/consts.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/consts.py new file mode 100644 index 0000000..d636613 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/consts.py @@ -0,0 +1,130 @@ +from enum import Enum, Flag + + +class Platform(Enum): + """Supported gaming platforms""" + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" + Twitch = "twitch" + Minecraft = "minecraft" + GameSessions = "gamesessions" + Nuuvem = "nuuvem" + FXStore = "fxstore" + IndieGala = "indiegala" + Playfire = "playfire" + Oculus = "oculus" + Test = "test" + + +class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" + + +class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + + +class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ + None_ = 0 + Installed = 1 + Running = 2 diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/errors.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/errors.py new file mode 100644 index 0000000..f53479f --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/errors.py @@ -0,0 +1,75 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +assert UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/jsonrpc.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..bd5ab64 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/jsonrpc.py @@ -0,0 +1,299 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +from galaxy.reader import StreamLineReader +from galaxy.task_manager import TaskManager + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + + def json(self): + obj = { + "code": self.code, + "message": self.message + } + + if self.data is not None: + obj["error"]["data"] = self.data + + return obj + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) + + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} + + if isinstance(sensitive_params, Iterable): + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} + + return params + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = StreamLineReader(reader) + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._task_manager = TaskManager("jsonrpc server") + + def register_method(self, name, callback, immediate, sensitive_params=False): + """ + Register method + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + def register_notification(self, name, callback, immediate, sensitive_params=False): + """ + Register notification + + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue + + def close(self): + logging.info("Closing JSON-RPC server - not more messages will be read") + self._active = False + + async def wait_closed(self): + await self._task_manager.wait() + + def _eof(self): + logging.info("Received EOF") + self.close() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, immediate, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if immediate: + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except asyncio.CancelledError: + self._send_error(request.id, Aborted()) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + self._task_manager.create_task(handle(), request.method) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.json() + } + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + self._task_manager = TaskManager("notification client") + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + async def close(self): + await self._task_manager.wait() + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + self._task_manager.create_task(self._writer.drain(), "drain") + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/plugin.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/plugin.py new file mode 100644 index 0000000..e2330bb --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/plugin.py @@ -0,0 +1,805 @@ +import asyncio +import dataclasses +import json +import logging +import logging.handlers +import sys +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union + +from galaxy.api.consts import Feature +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.task_manager import TaskManager + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # filter None values + def dict_factory(elements): + return {k: v for k, v in elements if v is not None} + + return dataclasses.asdict(o, dict_factory=dict_factory) + if isinstance(o, Enum): + return o.value + return super().default(o) + + +class Plugin: + """Use and override methods of this class to create a new platform integration.""" + + def __init__(self, platform, version, reader, writer, handshake_token): + logging.info("Creating plugin for platform %s, version %s", platform.value, version) + self._platform = platform + self._version = version + + self._features: Set[Feature] = set() + self._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + self._persistent_cache = dict() + + self._internal_task_manager = TaskManager("plugin internal") + self._external_task_manager = TaskManager("plugin external") + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + immediate=True, + sensitive_params="data" + ) + self._register_method("ping", self._ping, internal=True, immediate=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + self._register_method( + "import_owned_games", + self.get_owned_games, + result_name="owned_games" + ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.close() + await self.wait_closed() + + @property + def features(self) -> List[Feature]: + return list(self._features) + + @property + def persistent_cache(self) -> Dict[str, str]: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: + return False + return True + + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def wrap_result(result): + if result_name: + result = { + result_name: result + } + return result + + if immediate: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + if not internal: + handler_ = self._wrap_external_method(handler, name) + else: + handler_ = handler + result = await handler_(*args, **kwargs) + return wrap_result(result) + + self._server.register_method(name, method, False, sensitive_params) + + def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): + if not internal and not immediate: + handler = self._wrap_external_method(handler, name) + self._server.register_notification(name, handler, immediate, sensitive_params) + + def _wrap_external_method(self, handler, name: str): + async def wrapper(*args, **kwargs): + return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper + + async def run(self): + """Plugin's main coroutine.""" + await self._server.run() + + def close(self) -> None: + if not self._active: + return + + logging.info("Closing plugin") + self._server.close() + self._external_task_manager.cancel() + self._internal_task_manager.create_task(self.shutdown(), "shutdown") + self._active = False + + async def wait_closed(self) -> None: + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + await self._server.wait_closed() + await self._notification_client.close() + + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + return self._external_task_manager.create_task(coro, description) + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) + + async def _shutdown(self): + logging.info("Shutting down") + self.close() + await self._external_task_manager.wait() + await self._internal_task_manager.wait() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._internal_task_manager.create_task(self._pass_control(), "tick") + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials: Dict[str, Any]) -> None: + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + # temporary solution for persistent_cache vs credentials issue + self.persistent_cache['credentials'] = credentials # type: ignore + + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game: Game) -> None: + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id: str) -> None: + """Notify the client to remove game from the list of owned games + of the currently authenticated user. + + :param game_id: the id of the game to remove from the list of owned games + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ + params = {"game_id": game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game: Game) -> None: + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ + params = {"owned_game": game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: + """Notify the client to unlock an achievement for a specific game. + + :param game_id: the id of the game for which to unlock an achievement. + :param achievement: achievement to unlock. + """ + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: + params = { + "game_id": game_id, + "unlocked_achievements": achievements + } + self._notification_client.notify("game_achievements_import_success", params) + + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_achievements_import_failure", params) + + def _achievements_import_finished(self) -> None: + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game: LocalGame) -> None: + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + await asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ + params = {"local_game": local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user: FriendInfo) -> None: + """Notify the client to add a user to friends list of the currently authenticated user. + + :param user: FriendInfo of a user that the client will add to friends list + """ + params = {"friend_info": user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id: str) -> None: + """Notify the client to remove a user from friends list of the currently authenticated user. + + :param user_id: id of the user to remove from friends list + """ + params = {"user_id": user_id} + self._notification_client.notify("friend_removed", params) + + def update_game_time(self, game_time: GameTime) -> None: + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ + params = {"game_time": game_time} + self._notification_client.notify("game_time_updated", params) + + def _game_time_import_success(self, game_time: GameTime) -> None: + params = {"game_time": game_time} + self._notification_client.notify("game_time_import_success", params) + + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._notification_client.notify("game_time_import_failure", params) + + def _game_times_import_finished(self) -> None: + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self) -> None: + """Notify the client that integration has lost authentication for the + current user and is unable to perform actions which would require it. + """ + self._notification_client.notify("authentication_lost", None) + + def push_cache(self) -> None: + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache}, + sensitive_params="data" + ) + + # handlers + def handshake_complete(self) -> None: + """This method is called right after the handshake with the GOG Galaxy Client is complete and + before any other operations are called by the GOG Galaxy Client. + Persistent cache is available when this method is called. + Override it if you need to do additional plugin initializations. + This method is called internally.""" + + def tick(self) -> None: + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + + """ + + async def shutdown(self) -> None: + """This method is called on integration shutdown. + Override it to implement tear down. + This method is called by the GOG Galaxy Client.""" + + # methods + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: + """Override this method to handle user authentication. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another url. + This method is called by the GOG Galaxy Client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: + """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. + This method should either return galaxy.api.types.Authentication if the authentication is finished + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy Client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ + raise NotImplementedError() + + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ + raise NotImplementedError() + + async def _start_achievements_import(self, game_ids: List[str]) -> None: + if self._achievements_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self._external_task_manager.create_task( + import_games_achievements(game_ids, context), + "unlocked achievements import", + handle_exceptions=False + ) + self._achievements_import_in_progress = True + + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which achievements are imported + :return: context + """ + return None + + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ + + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ + raise NotImplementedError() + + async def launch_game(self, game_id: str) -> None: + """Override this method to launch the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to launch + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ + raise NotImplementedError() + + async def install_game(self, game_id: str) -> None: + """Override this method to install the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to install + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ + raise NotImplementedError() + + async def uninstall_game(self, game_id: str) -> None: + """Override this method to uninstall the game + identified by the provided game_id. + This method is called by the GOG Galaxy Client. + + :param str game_id: the id of the game to uninstall + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ + raise NotImplementedError() + + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. Preferably minimized to tray. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy Client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ + raise NotImplementedError() + + async def _start_game_times_import(self, game_ids: List[str]) -> None: + if self._game_times_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self._external_task_manager.create_task( + import_game_times(game_ids, context), + "game times import", + handle_exceptions=False + ) + self._game_times_import_in_progress = True + + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + + +def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info("sockname") + logging.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + + try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/types.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/types.py new file mode 100644 index 0000000..37d55a3 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/types.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState + +@dataclass +class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ + user_id: str + user_name: str + +@dataclass +class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param cookies: browser initial set of cookies + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + """ + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ + game_id: str + local_game_state: LocalGameState + +@dataclass +class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + +@dataclass +class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ + game_id: str + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/http.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/http.py new file mode 100644 index 0000000..615daa0 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/http.py @@ -0,0 +1,144 @@ +""" +This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. + +It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. +Examplary simple web service could looks like: + + .. code-block:: python + + import logging + from galaxy.http import create_client_session, handle_exception + + class BackendClient: + AUTH_URL = 'my-integration.com/auth' + HEADERS = { + "My-Custom-Header": "true", + } + def __init__(self): + self._session = create_client_session(headers=self.HEADERS) + + async def authenticate(self): + await self._session.request('POST', self.AUTH_URL) + + async def close(self): + # to be called on plugin shutdown + await self._session.close() + + async def _authorized_request(self, method, url, *args, **kwargs): + with handle_exceptions(): + return await self._session.request(method, url, *args, **kwargs) +""" + +import asyncio +import ssl +from contextlib import contextmanager +from http import HTTPStatus + +import aiohttp +import certifi +import logging + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) + + +#: Default limit of the simultaneous connections for ssl connector. +DEFAULT_LIMIT = 20 +#: Default timeout in seconds used for client session. +DEFAULT_TIMEOUT = 60 + + +class HttpClient: + """ + .. deprecated:: 0.41 + Use http module functions instead + """ + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + + async def close(self): + """Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`""" + await self._session.close() + + async def request(self, method, url, *args, **kwargs): + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: + """ + Creates TCP connector with resonable defaults. + For details about available parameters refer to + `aiohttp.TCPConnector `_ + """ + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: + """ + Creates client session with resonable defaults. + For details about available parameters refer to + `aiohttp.ClientSession `_ + + Examplary customization: + + .. code-block:: python + + from galaxy.http import create_client_session, create_tcp_connector + + session = create_client_session( + headers={ + "Keep-Alive": "true" + }, + connector=create_tcp_connector(limit=40), + timeout=100) + """ + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + + +@contextmanager +def handle_exception(): + """ + Context manager translating network related exceptions + to custom :mod:`~galaxy.api.errors`. + """ + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/proc_tools.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/reader.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/registry_monitor.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/registry_monitor.py new file mode 100644 index 0000000..02b1a5a --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/registry_monitor.py @@ -0,0 +1,98 @@ +import platform +if platform.system().lower() == "windows": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is None: + return False + + self._set_key_update_notification() + return True + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/task_manager.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/task_manager.py new file mode 100644 index 0000000..1ff20e2 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/task_manager.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from collections import OrderedDict +from itertools import count + +class TaskManager: + def __init__(self, name): + self._name = name + self._tasks = OrderedDict() + self._task_counter = count() + + def create_task(self, coro, description, handle_exceptions=True): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + result = await coro + logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + return result + except asyncio.CancelledError: + if handle_exceptions: + logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + else: + raise + except Exception: + if handle_exceptions: + logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + else: + raise + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + def cancel(self): + for task in self._tasks.values(): + task.cancel() + + async def wait(self): + # Tasks can spawn other tasks + while True: + tasks = self._tasks.values() + if not tasks: + return + await asyncio.gather(*tasks, return_exceptions=True) + + + diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/tools.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/tools.py new file mode 100644 index 0000000..8cb5540 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/tools.py @@ -0,0 +1,22 @@ +import io +import os +import zipfile +from glob import glob + + +def zip_folder(folder): + files = glob(os.path.join(folder, "**"), recursive=True) + files = [file.replace(folder + os.sep, "") for file in files] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + zipf.write(os.path.join(folder, file), arcname=file) + return zip_buffer + + +def zip_folder_to_file(folder, filename): + zip_content = zip_folder(folder).getbuffer() + with open(filename, "wb") as archive: + archive.write(zip_content) diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/mock.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/mock.py new file mode 100644 index 0000000..b439671 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/mock.py @@ -0,0 +1,31 @@ +import asyncio +from unittest.mock import MagicMock + + +class AsyncMock(MagicMock): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def coroutine_mock(): + """ + .. deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) + corofunc.coro = coro + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json new file mode 100644 index 0000000..841a5f8 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "GOG Galaxy PC Engine RetroArch plugin", + "platform": "pce", + "guid": "c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a", + "version": "0.1", + "description": "Galaxy Plugin to add PC Engine games and start them with the RetroArch emulator", + "author": "jshackles", + "email": "jshackles@gmail.com", + "url": "https://github.com/jshackles/RetroGOG", + "script": "plugin.py" +} diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py new file mode 100644 index 0000000..59d0258 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py @@ -0,0 +1,147 @@ +import asyncio +import subprocess +import sys +import json, urllib.request, os, os.path +import user_config, corrections +import datetime +import logging +import time +from collections import namedtuple +from typing import Any, Callable, Dict, List, NewType, Optional +from galaxy.api.consts import LicenseType, LocalGameState, Platform +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.types import Achievement, Authentication, Game, LicenseInfo, LocalGame, GameTime + +from version import __version__ as version + +class Retroarch(Plugin): + + def __init__(self, reader, writer, token): + super().__init__(Platform.PcEngine, version, reader, writer, token) + self.game_cache = [] + self.playlist_path = user_config.emu_path + "playlists/NEC - PC Engine - TurboGrafx 16.lpl" + self.proc = None + self.game_run = "" + + async def authenticate(self, stored_credentials=None): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def pass_login_credentials(self, step, credentials, cookies): + creds = {} + creds["user"] = "RAUser" + self.store_credentials(creds) + return Authentication("RAUser", "Retroarch") + + async def get_owned_games(self): + self.update_game_cache() + return self.game_cache + + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache + #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache + def update_game_cache(self): + game_list = [] + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + rom_path = entry["path"].split("#")[0] + if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + provided_name = entry["label"].split(" (")[0] + if provided_name in corrections.correction_list: + correct_name = corrections.correction_list[provided_name] + else: + correct_name = provided_name + game_list.append( + Game( + correct_name, + correct_name, + None, + LicenseInfo(LicenseType.SinglePurchase, None) + ) + ) + + #adds games when added while running + for entry in game_list: + if entry not in self.game_cache: + self.game_cache.append(entry) + self.add_game(entry) + + #removes games when removed while running + for entry in self.game_cache: + if entry not in game_list: + self.game_cache.remove(entry) + self.remove_game(entry.game_id) + + #runs update_game_cache in case it is started before get_owned_games. If it runs after it, it just returns self.game_cache with each game as installed + async def get_local_games(self): + if not self.game_cache: + self.update_game_cache() + local_game_list = [] + for game_entry in self.game_cache: + local_game_list.append(LocalGame(game_entry.game_id, 1)) + return local_game_list + + # Only as placeholders so the launch game feature is recognized + async def install_game(self, game_id): + pass + + async def uninstall_game(self, game_id): + pass + + def shutdown(self): + pass + + #potentially give user more customization possibilities like starting in fullscreen etc + async def launch_game(self, game_id): + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for entry in playlist_dict["items"]: + if game_id == entry["label"].split(" (")[0]: + self.update_local_game_status(LocalGame(game_id, 2)) + self.game_run = entry["label"].split(" (")[0] + self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) + break + + #imports retroarch playtime if existent. For this to work, activate "Save runtime log (aggregate)" in RetroArch settings -> Savings + async def get_game_time(self, game_id: str, context:any): + file_path = "" + time = 0 + last_played = None + + if os.path.isfile(self.playlist_path): + with open(self.playlist_path) as playlist_json: + playlist_dict = json.load(playlist_json) + for rom in playlist_dict["items"]: + if game_id == rom["label"].split(" (")[0]: + file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + if os.path.isfile(file_path): + with open(file_path) as json_data: + time_data = json.load(json_data) + last_played = datetime.datetime.timestamp(datetime.datetime.strptime(time_data["last_played"],'%Y-%m-%d %H:%M:%S')) + min_data = datetime.datetime.strptime(time_data["runtime"], '%H:%M:%S') + time = min_data.hour*60 + min_data.minute + return GameTime(game_id, time, last_played) + + #checks if game is (still) running, adjusts game_cache and game_time + def tick(self): + try: + if self.proc.poll() is not None: + self.update_local_game_status(LocalGame(self.game_run, 1)) + self.update_game_time(self.get_game_time(self.game_run,None)) + self.proc = None + except AttributeError: + pass + + self.update_game_cache() + self.get_local_games() + +def main(): + create_and_run_plugin(Retroarch, sys.argv) + +if __name__ == "__main__": + main() diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/user_config.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/user_config.py new file mode 100644 index 0000000..7b5c4ef --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/user_config.py @@ -0,0 +1,12 @@ +# Enter the path for your isos and RetroArch here. Be sure to add a "/" at the end of each path. +# example: +# emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ + +rom_path = "" +emu_path = "" + +# Enter your core DLL file here, be sure to include the file extension +# example: +# core = "mednafen_pce_fast_libretro.dll" + +core = "" \ No newline at end of file diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py new file mode 100644 index 0000000..11d27f8 --- /dev/null +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py @@ -0,0 +1 @@ +__version__ = '0.1' From 91581d059690e4a803a94f83adbe5a46d5a721bf Mon Sep 17 00:00:00 2001 From: jshackles Date: Tue, 25 Aug 2020 16:26:37 -0700 Subject: [PATCH 23/37] ADD: galaxy_api.zip --- galaxy_api.zip | Bin 0 -> 18488 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 galaxy_api.zip diff --git a/galaxy_api.zip b/galaxy_api.zip new file mode 100644 index 0000000000000000000000000000000000000000..492e55b314de275334a66e928a6559cf064333ae GIT binary patch literal 18488 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 F2LP`FZqEP! literal 0 HcmV?d00001 From a7899e794483223cbfbf5b8304ca26be3936766e Mon Sep 17 00:00:00 2001 From: jshackles Date: Thu, 27 Aug 2020 10:54:18 -0700 Subject: [PATCH 24/37] REMOVE: corrections feature GOG has a better matching algorithm now and a new back-end database tool. From what I can tell, this feature is no longer needed. --- .../corrections.py | 1 - 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py | 10 +++------- .../corrections.py | 1 - 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py | 10 +++------- .../corrections.py | 1 - atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py | 10 +++------- dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/corrections.py | 1 - dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py | 10 +++------- gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/corrections.py | 1 - gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py | 10 +++------- .../corrections.py | 1 - gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py | 10 +++------- .../corrections.py | 1 - gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py | 10 +++------- .../corrections.py | 1 - jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py | 10 +++------- .../corrections.py | 8 -------- n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py | 10 +++------- .../corrections.py | 1 - ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py | 10 +++------- .../corrections.py | 1 - nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py | 10 +++------- .../corrections.py | 6 ------ nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py | 10 +++------- .../corrections.py | 1 - nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py | 10 +++------- .../corrections.py | 1 - pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py | 10 +++------- .../corrections.py | 1 - ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py | 10 +++------- .../corrections.py | 1 - ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py | 10 +++------- .../corrections.py | 1 - psp_05487532-ba29-411b-b799-784262d275bd/plugin.py | 10 +++------- .../corrections.py | 1 - saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py | 10 +++------- .../corrections.py | 1 - segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py | 10 +++------- .../corrections.py | 7 ------- segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py | 10 +++------- .../corrections.py | 1 - sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py | 10 +++------- .../corrections.py | 4 ---- snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py | 10 +++------- 44 files changed, 66 insertions(+), 197 deletions(-) delete mode 100644 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/corrections.py delete mode 100644 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/corrections.py delete mode 100644 atari_830528d9-e621-48e9-8ed4-e03a4853843e/corrections.py delete mode 100644 dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/corrections.py delete mode 100644 gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/corrections.py delete mode 100644 gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/corrections.py delete mode 100644 gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/corrections.py delete mode 100644 jaguar_b9773549-9c20-4729-b23d-f683762ce73a/corrections.py delete mode 100644 n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/corrections.py delete mode 100644 ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/corrections.py delete mode 100644 nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/corrections.py delete mode 100644 nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py delete mode 100644 nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/corrections.py delete mode 100644 pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/corrections.py delete mode 100644 ps1_ff02c67d-5962-4e79-a3a3-928814edb270/corrections.py delete mode 100644 ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/corrections.py delete mode 100644 psp_05487532-ba29-411b-b799-784262d275bd/corrections.py delete mode 100644 saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/corrections.py delete mode 100644 segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/corrections.py delete mode 100644 segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py delete mode 100644 sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/corrections.py delete mode 100644 snes_bc831044-f772-4391-8c22-529f42cb9799/corrections.py diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/corrections.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py index 4f7188c..d56be9b 100644 --- a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/corrections.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py index 28b33b9..8d25ca3 100644 --- a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/corrections.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py index af5955f..9d13660 100644 --- a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/corrections.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py index b05c83b..e1296bb 100644 --- a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/corrections.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py index b7909e8..a25c325 100644 --- a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/corrections.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py index 0283202..3576dcc 100644 --- a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/corrections.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py index ee0a705..995e98d 100644 --- a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/corrections.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py index a2f91a7..5965571 100644 --- a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/corrections.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/corrections.py deleted file mode 100644 index 7a2d283..0000000 --- a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/corrections.py +++ /dev/null @@ -1,8 +0,0 @@ -correction_list = {} - -correction_list["Legend of Zelda, The - Majora's Mask"] = "The Legend of Zelda - Majora's Mask" -correction_list["Legend of Zelda, The - Ocarina of Time"] = "The Legend of Zelda - Ocarina of Time" -correction_list["Doubutsu no Mori"] = "Animal Forest" -correction_list["Bomberman 64 - The Second Attack!"] = "Bomberman 64: The Second Attack" -correction_list["Tarzan"] = "Disney's Tarzan" -correction_list["RR64 - Ridge Racer 64"] = "Ridge Racer 64" \ No newline at end of file diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py index 5e36955..cdf09be 100644 --- a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -52,14 +52,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/corrections.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py index 756b412..5f33da8 100644 --- a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/corrections.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py index fc7a6b7..2ffd118 100644 --- a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py deleted file mode 100644 index 60de926..0000000 --- a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/corrections.py +++ /dev/null @@ -1,6 +0,0 @@ -correction_list = {} - -correction_list["Dragon Warrior"] = "Dragon Quest" -correction_list["Dragon Warrior II"] = "Dragon Quest II" -correction_list["Dragon Warrior III"] = "Dragon Quest III" -correction_list["Dragon Warrior IV"] = "Dragon Quest IV" \ No newline at end of file diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py index b9f6d48..efebc67 100644 --- a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/corrections.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py index 1e609d6..8f479c6 100644 --- a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/corrections.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py index 59d0258..48a9c97 100644 --- a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/corrections.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py index 7a3130a..b62ad82 100644 --- a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/corrections.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py index f5547d9..02fd0a5 100644 --- a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/corrections.py b/psp_05487532-ba29-411b-b799-784262d275bd/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/psp_05487532-ba29-411b-b799-784262d275bd/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py index 70ab148..dc1051e 100644 --- a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py +++ b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/corrections.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py index a6830de..c79ebb5 100644 --- a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/corrections.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py index 6b485e5..73ba879 100644 --- a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py deleted file mode 100644 index d005d29..0000000 --- a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/corrections.py +++ /dev/null @@ -1,7 +0,0 @@ -correction_list = {} - -correction_list["Sonic Spinball"] = "Sonic the Hedgehog: Spinball" -correction_list["Adventures of Batman & Robin, The"] = "The Adventures of Batman & Robin (Genesis)" -correction_list["Alien 3"] = "Alien\u00b3" -correction_list["Batman - The Video Game"] = "Batman" -correction_list["Chakan"] = "Chakan: The Forever Man" \ No newline at end of file diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py index 5d01f9e..6a59fa4 100644 --- a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/corrections.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/corrections.py deleted file mode 100644 index 401ed9e..0000000 --- a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/corrections.py +++ /dev/null @@ -1 +0,0 @@ -correction_list = {} \ No newline at end of file diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py index 9be919d..ce6a112 100644 --- a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/corrections.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/corrections.py deleted file mode 100644 index 3f4248c..0000000 --- a/snes_bc831044-f772-4391-8c22-529f42cb9799/corrections.py +++ /dev/null @@ -1,4 +0,0 @@ -correction_list = {} - -correction_list["Final Fantasy II"] = "Final Fantasy IV" -correction_list["Final Fantasy III"] = "Final Fantasy VI" \ No newline at end of file diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py index dc18fcd..4681c1d 100644 --- a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py @@ -2,7 +2,7 @@ import subprocess import sys import json, urllib.request, os, os.path -import user_config, corrections +import user_config import datetime import logging import time @@ -51,14 +51,10 @@ def update_game_cache(self): rom_path = entry["path"].split("#")[0] if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] - if provided_name in corrections.correction_list: - correct_name = corrections.correction_list[provided_name] - else: - correct_name = provided_name game_list.append( Game( - correct_name, - correct_name, + provided_name, + provided_name, None, LicenseInfo(LicenseType.SinglePurchase, None) ) From c69ad8c481874efa6822a061b1e85819cfb36041 Mon Sep 17 00:00:00 2001 From: jshackles Date: Thu, 27 Aug 2020 16:24:40 -0700 Subject: [PATCH 25/37] BUILD: 0.3 --- 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json | 2 +- 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py | 4 ++-- 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py | 1 - 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py | 2 +- 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json | 2 +- 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py | 4 ++-- 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/user_config.py | 1 - 3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py | 2 +- README.md | 2 +- atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json | 2 +- atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py | 4 ++-- atari_830528d9-e621-48e9-8ed4-e03a4853843e/user_config.py | 1 - atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py | 2 +- dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json | 2 +- dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py | 4 ++-- dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/user_config.py | 1 - dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py | 2 +- gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json | 2 +- gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py | 4 ++-- gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/user_config.py | 1 - gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py | 2 +- gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json | 2 +- gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py | 4 ++-- gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/user_config.py | 1 - gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py | 2 +- gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json | 2 +- gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py | 4 ++-- gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/user_config.py | 1 - gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py | 2 +- jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json | 2 +- jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py | 4 ++-- jaguar_b9773549-9c20-4729-b23d-f683762ce73a/user_config.py | 1 - jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py | 2 +- n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json | 2 +- n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py | 4 ++-- n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/user_config.py | 1 - n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py | 2 +- ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json | 2 +- ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py | 4 ++-- ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/user_config.py | 1 - ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py | 2 +- nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json | 2 +- nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py | 4 ++-- nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/user_config.py | 1 - nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py | 2 +- nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json | 2 +- nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py | 4 ++-- nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/user_config.py | 1 - nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py | 2 +- nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json | 2 +- nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py | 4 ++-- nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/user_config.py | 1 - nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py | 2 +- pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json | 2 +- pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py | 4 ++-- pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/user_config.py | 1 - pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py | 2 +- ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json | 2 +- ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py | 4 ++-- ps1_ff02c67d-5962-4e79-a3a3-928814edb270/user_config.py | 1 - ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py | 2 +- ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json | 2 +- ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py | 4 ++-- ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py | 1 - ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py | 2 +- psp_05487532-ba29-411b-b799-784262d275bd/manifest.json | 2 +- psp_05487532-ba29-411b-b799-784262d275bd/plugin.py | 4 ++-- psp_05487532-ba29-411b-b799-784262d275bd/user_config.py | 1 - psp_05487532-ba29-411b-b799-784262d275bd/version.py | 2 +- saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json | 2 +- saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py | 4 ++-- saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/user_config.py | 1 - saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py | 2 +- segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json | 2 +- segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py | 4 ++-- segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/user_config.py | 1 - segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py | 2 +- segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json | 2 +- segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py | 4 ++-- segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/user_config.py | 1 - segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py | 2 +- sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json | 2 +- sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py | 4 ++-- sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/user_config.py | 1 - sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py | 2 +- snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json | 2 +- snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py | 4 ++-- snes_bc831044-f772-4391-8c22-529f42cb9799/user_config.py | 1 - snes_bc831044-f772-4391-8c22-529f42cb9799/version.py | 2 +- 89 files changed, 89 insertions(+), 111 deletions(-) diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json index be16471..75dbaa6 100644 --- a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy 3DO RetroArch plugin", "platform": "3do", "guid": "9d81c0ec-5646-4b1a-b809-e7e61e1d3577", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add 3DO games and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py index d56be9b..df4bc4a 100644 --- a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py index 2b53f51..b3dd8e2 100644 --- a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py index 11d27f8..cce384d 100644 --- a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json index 28354df..078e5c0 100644 --- a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo 3DS RetroArch plugin", "platform": "3ds", "guid": "f6acd3ed-2c31-47d6-bae4-07b6714c1e55", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Nintendo 3DS roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py index 8d25ca3..16c19c9 100644 --- a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/user_config.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/user_config.py index 16ad265..3df833b 100644 --- a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/user_config.py +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py index 11d27f8..cce384d 100644 --- a/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py +++ b/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/README.md b/README.md index 2aace3e..30fe20a 100644 --- a/README.md +++ b/README.md @@ -58,5 +58,5 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in 2. Extract the ZIP file. 3. Copy the folders to your Galaxy plugin folder (standard is: *C:\Users\USERNAME\AppData\Local\GOG.com\Galaxy\plugins\installed*) 4. For each integration, open the file *user_config.py* with an editor. -5. Add your emulator and roms path, along with your preferred core as described in the file. +5. Add your emulator path, along with your preferred core as described in the file. 6. (Re)start Galaxy 2.0 and connect the integration. \ No newline at end of file diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json index ce98c9f..485da37 100644 --- a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Atari 2600 RetroArch plugin", "platform": "atari", "guid": "830528d9-e621-48e9-8ed4-e03a4853843e", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Atari 2600 roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py index 9d13660..408822f 100644 --- a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/user_config.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/user_config.py index 9b90785..5d06aff 100644 --- a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/user_config.py +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py index 11d27f8..cce384d 100644 --- a/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py +++ b/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json index e2a3c11..632c6ce 100644 --- a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Sega Dreamcast RetroArch plugin", "platform": "dc", "guid": "5d181ffd-48dc-4330-aa58-6f646e76a5c8", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Sega Dreamcast isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py index e1296bb..f1e2426 100644 --- a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/user_config.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/user_config.py index 5980f35..3d14f0e 100644 --- a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/user_config.py +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py index 11d27f8..cce384d 100644 --- a/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py +++ b/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json index e995295..0e3d38c 100644 --- a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo Gameboy RetroArch plugin", "platform": "ngameboy", "guid": "4345afe1-a2c3-4c58-93d3-373c53a90a92", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Nintendo GameBoy roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py index a25c325..272d8a4 100644 --- a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/user_config.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/user_config.py index 634b179..98bea83 100644 --- a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/user_config.py +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py index 11d27f8..cce384d 100644 --- a/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py +++ b/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json index f106180..b5c3b85 100644 --- a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo Gameboy Advance RetroArch plugin", "platform": "ngameboy", "guid": "16a78ef5-fba6-4629-b83c-ef47adab5aab", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Nintendo GameBoy Advance roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py index 3576dcc..3582134 100644 --- a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/user_config.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/user_config.py index 634b179..98bea83 100644 --- a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/user_config.py +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py index 11d27f8..cce384d 100644 --- a/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py +++ b/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json index d0f31fd..a3cbb71 100644 --- a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo Gameboy Color RetroArch plugin", "platform": "ngameboy", "guid": "9b53fc85-af7c-4ce2-af31-0d95234d783a", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Nintendo GameBoy Color roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py index 995e98d..7d86534 100644 --- a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/user_config.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/user_config.py index 634b179..98bea83 100644 --- a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/user_config.py +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py index 11d27f8..cce384d 100644 --- a/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py +++ b/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json index f583814..25a5f60 100644 --- a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Atari Jaguar RetroArch plugin", "platform": "jaguar", "guid": "b9773549-9c20-4729-b23d-f683762ce73a", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Atari Jaguar roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py index 5965571..76bdef2 100644 --- a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/user_config.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/user_config.py index b17325b..b7092fc 100644 --- a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/user_config.py +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py index 11d27f8..cce384d 100644 --- a/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py +++ b/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json index b3cbdf7..f3653cd 100644 --- a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json @@ -2,7 +2,7 @@ "name": "Galaxy n64 RetroArch plugin", "platform": "n64", "guid": "a3824d31-c2d3-4a1a-b321-7d0764da5513", - "version": "0.2", + "version": "0.3", "description": "Galaxy Plugin to add n64 roms and start them with RetroArch emulator", "author": "riku55", "email": "", diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py index cdf09be..a1c4de1 100644 --- a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py @@ -50,7 +50,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -115,7 +115,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/user_config.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/user_config.py index 41e01cf..db4e995 100644 --- a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/user_config.py +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py index b650ceb..cce384d 100644 --- a/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py +++ b/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py @@ -1 +1 @@ -__version__ = '0.2' +__version__ = '0.3' diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json index a221414..0842bc3 100644 --- a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo Gamecube RetroArch plugin", "platform": "ncube", "guid": "602422b9-ced5-476e-911a-7fa0adf0f7f7", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Nintendo Gamecube isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py index 5f33da8..aaf736f 100644 --- a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/user_config.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/user_config.py index d7b4bc3..8706617 100644 --- a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/user_config.py +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py index 11d27f8..cce384d 100644 --- a/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py +++ b/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json index 9b5d479..54b5b91 100644 --- a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo DS RetroArch plugin", "platform": "nds", "guid": "4704ed29-f516-4fd8-8477-ddbcdb7cedfc", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Nintendo DS roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py index 2ffd118..ec97c1c 100644 --- a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/user_config.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/user_config.py index 6c45117..539eb5a 100644 --- a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/user_config.py +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py index 11d27f8..cce384d 100644 --- a/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py +++ b/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json index 5aedd5d..25ff115 100644 --- a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy NES RetroArch plugin", "platform": "nes", "guid": "e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add NES roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py index efebc67..716bbba 100644 --- a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/user_config.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/user_config.py index 8f6cb99..8faf7e2 100644 --- a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/user_config.py +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py index 11d27f8..cce384d 100644 --- a/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py +++ b/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json index 026048d..36ea8e9 100644 --- a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo Wii RetroArch plugin", "platform": "nwii", "guid": "2d0e97ac-0406-4e5f-a85b-ab5b1a042cba", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Nintendo Wii isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py index 8f479c6..913d651 100644 --- a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/user_config.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/user_config.py index d7b4bc3..8706617 100644 --- a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/user_config.py +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py index 11d27f8..cce384d 100644 --- a/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py +++ b/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json index 841a5f8..65b57d0 100644 --- a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy PC Engine RetroArch plugin", "platform": "pce", "guid": "c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add PC Engine games and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py index 48a9c97..3ed0819 100644 --- a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/user_config.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/user_config.py index 7b5c4ef..26b21db 100644 --- a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/user_config.py +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py index 11d27f8..cce384d 100644 --- a/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py +++ b/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json index 9882b83..ba635c4 100644 --- a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy PS1 RetroArch plugin", "platform": "psx", "guid": "ff02c67d-5962-4e79-a3a3-928814edb270", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add PS1 isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py index b62ad82..ef9d30a 100644 --- a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/user_config.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/user_config.py index 2b53f51..b3dd8e2 100644 --- a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/user_config.py +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py index 11d27f8..cce384d 100644 --- a/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py +++ b/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json index b7528e6..ed06077 100644 --- a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy PS2 RetroArch plugin", "platform": "ps2", "guid": "50ad79eb-393c-4f95-98ce-59f095ae47ea", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add PS2 isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py index 02fd0a5..d20a3fa 100644 --- a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py index 2b53f51..b3dd8e2 100644 --- a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py index 11d27f8..cce384d 100644 --- a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json b/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json index 1eb3361..a2977a8 100644 --- a/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json +++ b/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy PSP RetroArch plugin", "platform": "psp", "guid": "05487532-ba29-411b-b799-784262d275bd", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add PSP isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py index dc1051e..ff5137d 100644 --- a/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py +++ b/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/user_config.py b/psp_05487532-ba29-411b-b799-784262d275bd/user_config.py index 68ef7fc..e58403b 100644 --- a/psp_05487532-ba29-411b-b799-784262d275bd/user_config.py +++ b/psp_05487532-ba29-411b-b799-784262d275bd/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/psp_05487532-ba29-411b-b799-784262d275bd/version.py b/psp_05487532-ba29-411b-b799-784262d275bd/version.py index 11d27f8..cce384d 100644 --- a/psp_05487532-ba29-411b-b799-784262d275bd/version.py +++ b/psp_05487532-ba29-411b-b799-784262d275bd/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json index b662c2a..bbf35ae 100644 --- a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Sega Saturn RetroArch plugin", "platform": "saturn", "guid": "bd6ec091-8ee0-440a-9e26-71bbf21c05af", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Sega Saturn isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py index c79ebb5..57f310a 100644 --- a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/user_config.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/user_config.py index 771e35b..75c87bb 100644 --- a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/user_config.py +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py index 11d27f8..cce384d 100644 --- a/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py +++ b/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json index aa70ef7..2331196 100644 --- a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Sega CD RetroArch plugin", "platform": "segacd", "guid": "ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Sega CD isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py index 73ba879..59c3aba 100644 --- a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/user_config.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/user_config.py index 5de008e..8abbed4 100644 --- a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/user_config.py +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py index 11d27f8..cce384d 100644 --- a/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py +++ b/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json index a61d5e9..312c9b0 100644 --- a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Sega Genesis RetroArch plugin", "platform": "segag", "guid": "e3ac94e7-945e-459d-bc1e-676cff8173f9", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Sega Genesis roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py index 6a59fa4..7fe6f19 100644 --- a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/user_config.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/user_config.py index 5de008e..8abbed4 100644 --- a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/user_config.py +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py index 11d27f8..cce384d 100644 --- a/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py +++ b/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json index a47b7cc..20a53e2 100644 --- a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Sega Master System RetroArch plugin", "platform": "sms", "guid": "c6689bfb-7ba4-4d24-98e3-bd2dc339926b", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add Sega Master System roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py index ce6a112..c13d939 100644 --- a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/user_config.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/user_config.py index 5de008e..8abbed4 100644 --- a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/user_config.py +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py index 11d27f8..cce384d 100644 --- a/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py +++ b/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json b/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json index 42c5476..b0ea0c8 100644 --- a/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy SNES RetroArch plugin", "platform": "snes", "guid": "bc831044-f772-4391-8c22-529f42cb9799", - "version": "0.1", + "version": "0.3", "description": "Galaxy Plugin to add SNES roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py index 4681c1d..27d4b49 100644 --- a/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py @@ -49,7 +49,7 @@ def update_game_cache(self): playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] - if os.path.abspath(user_config.rom_path) in os.path.abspath(rom_path) and os.path.isfile(rom_path): + if os.path.isfile(rom_path): provided_name = entry["label"].split(" (")[0] game_list.append( Game( @@ -114,7 +114,7 @@ async def get_game_time(self, game_id: str, context:any): playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: if game_id == rom["label"].split(" (")[0]: - file_path = user_config.emu_path + "/playlists/logs/" + os.path.abspath(rom["path"]).split(os.path.abspath(user_config.rom_path) + "\\")[1][:-4] + ".lrtl" + file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: time_data = json.load(json_data) diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/user_config.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/user_config.py index cef5571..4d4d3a4 100644 --- a/snes_bc831044-f772-4391-8c22-529f42cb9799/user_config.py +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/user_config.py @@ -2,7 +2,6 @@ # example: # emu_path = "C:/Users/USERNAME/AppData/Roaming/RetroArch/ -rom_path = "" emu_path = "" # Enter your core DLL file here, be sure to include the file extension diff --git a/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py b/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py index 11d27f8..cce384d 100644 --- a/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py +++ b/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py @@ -1 +1 @@ -__version__ = '0.1' +__version__ = '0.3' From b5847e59a939d0235e1ddb0e2a04f44c84d4d37f Mon Sep 17 00:00:00 2001 From: jshackles Date: Fri, 28 Aug 2020 09:42:57 -0700 Subject: [PATCH 26/37] FIX: a few config plugin suggestions --- 3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py | 2 +- ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py index b3dd8e2..65e1a51 100644 --- a/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py +++ b/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/user_config.py @@ -6,6 +6,6 @@ # Enter your core DLL file here, be sure to include the file extension # example: -# core = "pcsx_rearmed_libretro.dll" +# core = "opera_libretro.dll" core = "" \ No newline at end of file diff --git a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py index b3dd8e2..98bd88f 100644 --- a/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py +++ b/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/user_config.py @@ -6,6 +6,6 @@ # Enter your core DLL file here, be sure to include the file extension # example: -# core = "pcsx_rearmed_libretro.dll" +# core = "play_libretro.dll" core = "" \ No newline at end of file From 49a03be1e2becb244f10d5e8608698ff98ffd16c Mon Sep 17 00:00:00 2001 From: jshackles Date: Fri, 28 Aug 2020 12:28:35 -0700 Subject: [PATCH 27/37] ADD: Installation GUI This configuration wizard is very much a work in progress, but it performs the basic functionality of locating the user's GOG Galaxy and Retroarch installation folders, downloading the required plugin files, and writing the relevant configuration data. More features and bugfixes will be added in the future. --- .gitignore | 4 + README.md | 11 +- RetroGOG.exe | Bin 0 -> 992256 bytes .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 .../galaxy/__init__.py | 0 .../galaxy/api/consts.py | 0 .../galaxy/api/errors.py | 0 .../galaxy/api/jsonrpc.py | 0 .../galaxy/api/plugin.py | 0 .../galaxy/api/types.py | 0 .../galaxy/http.py | 0 .../galaxy/proc_tools.py | 0 .../galaxy/reader.py | 0 .../galaxy/registry_monitor.py | 0 .../galaxy/task_manager.py | 0 .../galaxy/tools.py | 0 .../galaxy/unittest/mock.py | 0 .../manifest.json | 0 .../plugin.py | 0 .../user_config.py | 0 .../version.py | 0 src/RetroGOG.sln | 25 + src/RetroGOG/App.config | 6 + src/RetroGOG/Program.cs | 30 + src/RetroGOG/Properties/AssemblyInfo.cs | 36 + src/RetroGOG/Properties/Resources.Designer.cs | 173 ++ src/RetroGOG/Properties/Resources.resx | 154 ++ src/RetroGOG/Properties/Settings.Designer.cs | 30 + src/RetroGOG/Properties/Settings.settings | 7 + src/RetroGOG/Resources/arrow.png | Bin 0 -> 23060 bytes src/RetroGOG/Resources/downloadgog.png | Bin 0 -> 11904 bytes src/RetroGOG/Resources/galaxy logo.png | Bin 0 -> 147098 bytes src/RetroGOG/Resources/no.png | Bin 0 -> 13223 bytes src/RetroGOG/Resources/retroGOG.ico | Bin 0 -> 105803 bytes src/RetroGOG/Resources/retroGOG.png | Bin 0 -> 18991 bytes src/RetroGOG/Resources/retroarch.png | Bin 0 -> 5740 bytes src/RetroGOG/Resources/step_1.png | Bin 0 -> 33888 bytes src/RetroGOG/Resources/step_2.png | Bin 0 -> 16824 bytes src/RetroGOG/Resources/warn.png | Bin 0 -> 26620 bytes src/RetroGOG/Resources/yes.png | Bin 0 -> 10672 bytes src/RetroGOG/RetroGOG.csproj | 168 ++ src/RetroGOG/frmAbout.Designer.cs | 186 ++ src/RetroGOG/frmAbout.cs | 115 + src/RetroGOG/frmAbout.resx | 120 ++ src/RetroGOG/frmComplete.Designer.cs | 177 ++ src/RetroGOG/frmComplete.cs | 39 + src/RetroGOG/frmComplete.resx | 1889 ++++++++++++++++ src/RetroGOG/frmCoreSelect.Designer.cs | 104 + src/RetroGOG/frmCoreSelect.cs | 38 + src/RetroGOG/frmCoreSelect.resx | 120 ++ src/RetroGOG/frmDependencies.Designer.cs | 268 +++ src/RetroGOG/frmDependencies.cs | 130 ++ src/RetroGOG/frmDependencies.resx | 1895 +++++++++++++++++ src/RetroGOG/frmMain.Designer.cs | 197 ++ src/RetroGOG/frmMain.cs | 47 + src/RetroGOG/frmMain.resx | 1889 ++++++++++++++++ src/RetroGOG/frmPluginSelect.Designer.cs | 182 ++ src/RetroGOG/frmPluginSelect.cs | 196 ++ src/RetroGOG/frmPluginSelect.resx | 1892 ++++++++++++++++ src/RetroGOG/retroGOG.ico | Bin 0 -> 105803 bytes 416 files changed, 10125 insertions(+), 3 deletions(-) create mode 100644 .gitignore create mode 100644 RetroGOG.exe rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/__init__.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/api/consts.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/api/errors.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/api/jsonrpc.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/api/plugin.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/api/types.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/http.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/proc_tools.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/reader.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/registry_monitor.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/task_manager.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/tools.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/galaxy/unittest/mock.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/manifest.json (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/plugin.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/user_config.py (100%) rename {3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577 => plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577}/version.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/__init__.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/api/consts.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/api/errors.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/api/jsonrpc.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/api/plugin.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/api/types.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/http.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/proc_tools.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/reader.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/registry_monitor.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/task_manager.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/tools.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/galaxy/unittest/mock.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/manifest.json (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/plugin.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/user_config.py (100%) rename {3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55 => plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55}/version.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/__init__.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/api/consts.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/api/errors.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/api/jsonrpc.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/api/plugin.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/api/types.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/http.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/proc_tools.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/reader.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/registry_monitor.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/task_manager.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/tools.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/galaxy/unittest/mock.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/manifest.json (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/plugin.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/user_config.py (100%) rename {atari_830528d9-e621-48e9-8ed4-e03a4853843e => plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e}/version.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/__init__.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/api/consts.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/api/errors.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/api/jsonrpc.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/api/plugin.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/api/types.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/http.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/proc_tools.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/reader.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/registry_monitor.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/task_manager.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/tools.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/galaxy/unittest/mock.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/manifest.json (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/plugin.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/user_config.py (100%) rename {dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8 => plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8}/version.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/__init__.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/api/consts.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/api/errors.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/api/jsonrpc.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/api/plugin.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/api/types.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/http.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/proc_tools.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/reader.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/registry_monitor.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/task_manager.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/tools.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/galaxy/unittest/mock.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/manifest.json (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/plugin.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/user_config.py (100%) rename {gb_4345afe1-a2c3-4c58-93d3-373c53a90a92 => plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92}/version.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/__init__.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/api/consts.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/api/errors.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/api/jsonrpc.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/api/plugin.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/api/types.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/http.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/proc_tools.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/reader.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/registry_monitor.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/task_manager.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/tools.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/galaxy/unittest/mock.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/manifest.json (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/plugin.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/user_config.py (100%) rename {gba_16a78ef5-fba6-4629-b83c-ef47adab5aab => plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab}/version.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/__init__.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/api/consts.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/api/errors.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/api/jsonrpc.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/api/plugin.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/api/types.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/http.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/proc_tools.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/reader.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/registry_monitor.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/task_manager.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/tools.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/galaxy/unittest/mock.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/manifest.json (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/plugin.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/user_config.py (100%) rename {gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a => plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a}/version.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/__init__.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/api/consts.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/api/errors.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/api/jsonrpc.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/api/plugin.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/api/types.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/http.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/proc_tools.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/reader.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/registry_monitor.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/task_manager.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/tools.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/galaxy/unittest/mock.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/manifest.json (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/plugin.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/user_config.py (100%) rename {jaguar_b9773549-9c20-4729-b23d-f683762ce73a => plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a}/version.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/__init__.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/api/consts.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/api/errors.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/api/jsonrpc.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/api/plugin.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/api/types.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/http.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/proc_tools.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/reader.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/registry_monitor.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/task_manager.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/tools.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/galaxy/unittest/mock.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/manifest.json (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/plugin.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/user_config.py (100%) rename {n64_a3824d31-c2d3-4a1a-b321-7d0764da5513 => plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513}/version.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/__init__.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/api/consts.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/api/errors.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/api/jsonrpc.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/api/plugin.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/api/types.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/http.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/proc_tools.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/reader.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/registry_monitor.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/task_manager.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/tools.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/galaxy/unittest/mock.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/manifest.json (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/plugin.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/user_config.py (100%) rename {ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7 => plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7}/version.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/__init__.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/api/consts.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/api/errors.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/api/jsonrpc.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/api/plugin.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/api/types.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/http.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/proc_tools.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/reader.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/registry_monitor.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/task_manager.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/tools.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/galaxy/unittest/mock.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/manifest.json (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/plugin.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/user_config.py (100%) rename {nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc => plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc}/version.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/__init__.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/api/consts.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/api/errors.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/api/jsonrpc.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/api/plugin.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/api/types.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/http.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/proc_tools.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/reader.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/registry_monitor.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/task_manager.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/tools.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/galaxy/unittest/mock.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/manifest.json (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/plugin.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/user_config.py (100%) rename {nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad => plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad}/version.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/__init__.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/api/consts.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/api/errors.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/api/jsonrpc.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/api/plugin.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/api/types.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/http.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/proc_tools.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/reader.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/registry_monitor.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/task_manager.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/tools.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/galaxy/unittest/mock.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/manifest.json (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/plugin.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/user_config.py (100%) rename {nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba => plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba}/version.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/__init__.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/api/consts.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/api/errors.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/api/jsonrpc.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/api/plugin.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/api/types.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/http.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/proc_tools.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/reader.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/registry_monitor.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/task_manager.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/tools.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/galaxy/unittest/mock.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/manifest.json (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/plugin.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/user_config.py (100%) rename {pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a => plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a}/version.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/__init__.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/api/consts.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/api/errors.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/api/jsonrpc.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/api/plugin.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/api/types.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/http.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/proc_tools.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/reader.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/registry_monitor.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/task_manager.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/tools.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/galaxy/unittest/mock.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/manifest.json (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/plugin.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/user_config.py (100%) rename {ps1_ff02c67d-5962-4e79-a3a3-928814edb270 => plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270}/version.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/__init__.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/api/consts.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/api/errors.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/api/jsonrpc.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/api/plugin.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/api/types.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/http.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/proc_tools.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/reader.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/registry_monitor.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/task_manager.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/tools.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/galaxy/unittest/mock.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/manifest.json (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/plugin.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/user_config.py (100%) rename {ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea => plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea}/version.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/__init__.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/api/consts.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/api/errors.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/api/jsonrpc.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/api/plugin.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/api/types.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/http.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/proc_tools.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/reader.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/registry_monitor.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/task_manager.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/tools.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/galaxy/unittest/mock.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/manifest.json (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/plugin.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/user_config.py (100%) rename {psp_05487532-ba29-411b-b799-784262d275bd => plugins/psp_05487532-ba29-411b-b799-784262d275bd}/version.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/__init__.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/api/consts.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/api/errors.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/api/jsonrpc.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/api/plugin.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/api/types.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/http.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/proc_tools.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/reader.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/registry_monitor.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/task_manager.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/tools.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/galaxy/unittest/mock.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/manifest.json (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/plugin.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/user_config.py (100%) rename {saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af => plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af}/version.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/__init__.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/api/consts.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/api/errors.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/api/jsonrpc.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/api/plugin.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/api/types.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/http.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/proc_tools.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/reader.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/registry_monitor.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/task_manager.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/tools.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/galaxy/unittest/mock.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/manifest.json (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/plugin.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/user_config.py (100%) rename {segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2 => plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2}/version.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/__init__.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/api/consts.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/api/errors.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/api/jsonrpc.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/api/plugin.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/api/types.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/http.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/proc_tools.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/reader.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/registry_monitor.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/task_manager.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/tools.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/galaxy/unittest/mock.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/manifest.json (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/plugin.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/user_config.py (100%) rename {segag_e3ac94e7-945e-459d-bc1e-676cff8173f9 => plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9}/version.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/__init__.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/api/consts.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/api/errors.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/api/jsonrpc.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/api/plugin.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/api/types.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/http.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/proc_tools.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/reader.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/registry_monitor.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/task_manager.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/tools.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/galaxy/unittest/mock.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/manifest.json (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/plugin.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/user_config.py (100%) rename {sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b => plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b}/version.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/__init__.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/api/consts.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/api/errors.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/api/jsonrpc.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/api/plugin.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/api/types.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/http.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/proc_tools.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/reader.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/registry_monitor.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/task_manager.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/tools.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/galaxy/unittest/mock.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/manifest.json (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/plugin.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/user_config.py (100%) rename {snes_bc831044-f772-4391-8c22-529f42cb9799 => plugins/snes_bc831044-f772-4391-8c22-529f42cb9799}/version.py (100%) create mode 100644 src/RetroGOG.sln create mode 100644 src/RetroGOG/App.config create mode 100644 src/RetroGOG/Program.cs create mode 100644 src/RetroGOG/Properties/AssemblyInfo.cs create mode 100644 src/RetroGOG/Properties/Resources.Designer.cs create mode 100644 src/RetroGOG/Properties/Resources.resx create mode 100644 src/RetroGOG/Properties/Settings.Designer.cs create mode 100644 src/RetroGOG/Properties/Settings.settings create mode 100644 src/RetroGOG/Resources/arrow.png create mode 100644 src/RetroGOG/Resources/downloadgog.png create mode 100644 src/RetroGOG/Resources/galaxy logo.png create mode 100644 src/RetroGOG/Resources/no.png create mode 100644 src/RetroGOG/Resources/retroGOG.ico create mode 100644 src/RetroGOG/Resources/retroGOG.png create mode 100644 src/RetroGOG/Resources/retroarch.png create mode 100644 src/RetroGOG/Resources/step_1.png create mode 100644 src/RetroGOG/Resources/step_2.png create mode 100644 src/RetroGOG/Resources/warn.png create mode 100644 src/RetroGOG/Resources/yes.png create mode 100644 src/RetroGOG/RetroGOG.csproj create mode 100644 src/RetroGOG/frmAbout.Designer.cs create mode 100644 src/RetroGOG/frmAbout.cs create mode 100644 src/RetroGOG/frmAbout.resx create mode 100644 src/RetroGOG/frmComplete.Designer.cs create mode 100644 src/RetroGOG/frmComplete.cs create mode 100644 src/RetroGOG/frmComplete.resx create mode 100644 src/RetroGOG/frmCoreSelect.Designer.cs create mode 100644 src/RetroGOG/frmCoreSelect.cs create mode 100644 src/RetroGOG/frmCoreSelect.resx create mode 100644 src/RetroGOG/frmDependencies.Designer.cs create mode 100644 src/RetroGOG/frmDependencies.cs create mode 100644 src/RetroGOG/frmDependencies.resx create mode 100644 src/RetroGOG/frmMain.Designer.cs create mode 100644 src/RetroGOG/frmMain.cs create mode 100644 src/RetroGOG/frmMain.resx create mode 100644 src/RetroGOG/frmPluginSelect.Designer.cs create mode 100644 src/RetroGOG/frmPluginSelect.cs create mode 100644 src/RetroGOG/frmPluginSelect.resx create mode 100644 src/RetroGOG/retroGOG.ico diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f41865d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin +obj +*.suo +*.user \ No newline at end of file diff --git a/README.md b/README.md index 30fe20a..56b38b9 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in #### Working Features: - Add and launch retro games to your GOG Galaxy 2.0 library - Import RetroArch Playtime +- Configuration wizard for easy installation #### Future Features: -- Add configuration GUI for better usability - Add achievements to GOG via web API @@ -53,10 +53,15 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in 3. Navigate to the right until you're at *Import Content*, click on *Scan Directory*, navigate to the folder where your Roms are and click on *Scan this Directory*. 4. Navigate to *Settings*, click on *Saving*, go to the last option there *Save runtime log (aggregate)* and turn it on. -#### Setting up the Integration (for now, will be automated in the future): +#### Setting up RetroGOG: +1. Download the [RetroGOG configuration wizard](RetroGOG.exe). +2. Run the application and follow the on-screen instructions. +3. (Re)start Galaxy 2.0 and connect the integration. + +#### Manual setup instructions: 1. Download the integration (use clone or download). 2. Extract the ZIP file. -3. Copy the folders to your Galaxy plugin folder (standard is: *C:\Users\USERNAME\AppData\Local\GOG.com\Galaxy\plugins\installed*) +3. Copy the contents of the plugins folders to your Galaxy plugin folder (standard is: *C:\Users\USERNAME\AppData\Local\GOG.com\Galaxy\plugins\installed*) 4. For each integration, open the file *user_config.py* with an editor. 5. Add your emulator path, along with your preferred core as described in the file. 6. (Re)start Galaxy 2.0 and connect the integration. \ No newline at end of file diff --git a/RetroGOG.exe b/RetroGOG.exe new file mode 100644 index 0000000000000000000000000000000000000000..b6644490928e48010b90e6b9e4d5335e47e165be GIT binary patch literal 992256 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⃥y_4#1N+ToHNtfT@p{ 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

Y62;ySD&bX)OaoN~-UmQ|kk;>odLgo{grY@pU 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 z�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

f%8ns3Ia@*?dU6s^5^L^B8V8Me(&`JZJ24zDN7 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 + + + + + \ No newline at end of file diff --git a/src/RetroGOG/Program.cs b/src/RetroGOG/Program.cs new file mode 100644 index 0000000..c2d316c --- /dev/null +++ b/src/RetroGOG/Program.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace RetroGOG +{ + public static class Globals + { + public static string GOGPluginPath = ""; + public static string RAPath = ""; + public static string TempCore = ""; + } + + static class Program + { + ///

+ /// The main entry point for the application. + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new frmMain()); + } + } +} diff --git a/src/RetroGOG/Properties/AssemblyInfo.cs b/src/RetroGOG/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..b7c5f33 --- /dev/null +++ b/src/RetroGOG/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("RetroGOG")] +[assembly: AssemblyDescription("RetroGOG allows you to play your Retroarch games in GOG Galaxy 2.0")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("RetroGOG")] +[assembly: AssemblyProduct("RetroGOG")] +[assembly: AssemblyCopyright("Copyright © 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("2ba1681e-bb10-4334-aa93-03e439f7df9c")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/src/RetroGOG/Properties/Resources.Designer.cs b/src/RetroGOG/Properties/Resources.Designer.cs new file mode 100644 index 0000000..df63d03 --- /dev/null +++ b/src/RetroGOG/Properties/Resources.Designer.cs @@ -0,0 +1,173 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace RetroGOG.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("RetroGOG.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap arrow { + get { + object obj = ResourceManager.GetObject("arrow", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap downloadgog { + get { + object obj = ResourceManager.GetObject("downloadgog", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap galaxy_logo { + get { + object obj = ResourceManager.GetObject("galaxy_logo", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap no { + get { + object obj = ResourceManager.GetObject("no", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap retroarch { + get { + object obj = ResourceManager.GetObject("retroarch", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). + /// + internal static System.Drawing.Icon retroGOG { + get { + object obj = ResourceManager.GetObject("retroGOG", resourceCulture); + return ((System.Drawing.Icon)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap retroGOG1 { + get { + object obj = ResourceManager.GetObject("retroGOG1", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap step_1 { + get { + object obj = ResourceManager.GetObject("step_1", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap step_2 { + get { + object obj = ResourceManager.GetObject("step_2", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap warn { + get { + object obj = ResourceManager.GetObject("warn", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap yes { + get { + object obj = ResourceManager.GetObject("yes", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + } +} diff --git a/src/RetroGOG/Properties/Resources.resx b/src/RetroGOG/Properties/Resources.resx new file mode 100644 index 0000000..1ec1b93 --- /dev/null +++ b/src/RetroGOG/Properties/Resources.resx @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ..\Resources\arrow.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\downloadgog.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\galaxy logo.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\no.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\retroarch.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\retroGOG.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\retroGOG.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\step_1.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\step_2.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\warn.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\yes.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + \ No newline at end of file diff --git a/src/RetroGOG/Properties/Settings.Designer.cs b/src/RetroGOG/Properties/Settings.Designer.cs new file mode 100644 index 0000000..4db3be6 --- /dev/null +++ b/src/RetroGOG/Properties/Settings.Designer.cs @@ -0,0 +1,30 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace RetroGOG.Properties +{ + + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase + { + + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); + + public static Settings Default + { + get + { + return defaultInstance; + } + } + } +} diff --git a/src/RetroGOG/Properties/Settings.settings b/src/RetroGOG/Properties/Settings.settings new file mode 100644 index 0000000..3964565 --- /dev/null +++ b/src/RetroGOG/Properties/Settings.settings @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/RetroGOG/Resources/arrow.png b/src/RetroGOG/Resources/arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..16f6fbf14ed54944f657abf3c5d2d0353c25b482 GIT binary patch literal 23060 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 GkpBe~p6W6H literal 0 HcmV?d00001 diff --git a/src/RetroGOG/Resources/galaxy logo.png b/src/RetroGOG/Resources/galaxy logo.png new file mode 100644 index 0000000000000000000000000000000000000000..76d73a2af45151c956797ead6cc1025930f6a209 GIT binary patch literal 147098 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%0DUVn?D^qATM(Ia|Ut+N`EOPq96rKKfshWn20e>nZ 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 A=>Px# literal 0 HcmV?d00001 diff --git a/src/RetroGOG/Resources/no.png b/src/RetroGOG/Resources/no.png new file mode 100644 index 0000000000000000000000000000000000000000..5f2f8eb31548b9d119b4682decec9fc21b30af28 GIT binary patch literal 13223 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 literal 0 HcmV?d00001 diff --git a/src/RetroGOG/Resources/retroGOG.ico b/src/RetroGOG/Resources/retroGOG.ico new file mode 100644 index 0000000000000000000000000000000000000000..d251c327e41c79b6f6b23dc5e531aeeae916a6a4 GIT binary patch literal 105803 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 literal 0 HcmV?d00001 diff --git a/src/RetroGOG/Resources/retroarch.png b/src/RetroGOG/Resources/retroarch.png new file mode 100644 index 0000000000000000000000000000000000000000..fc02fe80d541a9363312cd6ade2b3589ee9cb86a GIT binary patch literal 5740 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 G=>Gsd64dG!+$ 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 RiA4eaD9S=)Dx^$;{vYEU7nuM6 literal 0 HcmV?d00001 diff --git a/src/RetroGOG/Resources/step_2.png b/src/RetroGOG/Resources/step_2.png new file mode 100644 index 0000000000000000000000000000000000000000..bb88559cefd63a01a087bfdefb05742302552bc4 GIT binary patch literal 16824 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%0DV7!}Ve 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 literal 0 HcmV?d00001 diff --git a/src/RetroGOG/RetroGOG.csproj b/src/RetroGOG/RetroGOG.csproj new file mode 100644 index 0000000..709796a --- /dev/null +++ b/src/RetroGOG/RetroGOG.csproj @@ -0,0 +1,168 @@ + + + + + Debug + AnyCPU + {2BA1681E-BB10-4334-AA93-03E439F7DF9C} + WinExe + RetroGOG + RetroGOG + v4.5 + 512 + true + publish\ + true + Disk + false + Foreground + 7 + Days + false + false + true + 0 + 1.0.0.%2a + false + false + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + retroGOG.ico + + + + + + + + + + + + + + + + + + Form + + + frmAbout.cs + + + Form + + + frmComplete.cs + + + Form + + + frmCoreSelect.cs + + + Form + + + frmDependencies.cs + + + Form + + + frmMain.cs + + + Form + + + frmPluginSelect.cs + + + + + frmAbout.cs + + + frmComplete.cs + + + frmCoreSelect.cs + + + frmDependencies.cs + + + frmMain.cs + + + frmPluginSelect.cs + + + ResXFileCodeGenerator + Resources.Designer.cs + Designer + + + True + Resources.resx + True + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + True + Settings.settings + True + + + + + + + + + + + + + + + + + + + + + + False + .NET Framework 3.5 SP1 + false + + + + \ No newline at end of file diff --git a/src/RetroGOG/frmAbout.Designer.cs b/src/RetroGOG/frmAbout.Designer.cs new file mode 100644 index 0000000..e7ec4de --- /dev/null +++ b/src/RetroGOG/frmAbout.Designer.cs @@ -0,0 +1,186 @@ +namespace RetroGOG +{ + partial class frmAbout + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.tableLayoutPanel = new System.Windows.Forms.TableLayoutPanel(); + this.logoPictureBox = new System.Windows.Forms.PictureBox(); + this.labelProductName = new System.Windows.Forms.Label(); + this.labelVersion = new System.Windows.Forms.Label(); + this.labelCopyright = new System.Windows.Forms.Label(); + this.labelCompanyName = new System.Windows.Forms.Label(); + this.textBoxDescription = new System.Windows.Forms.TextBox(); + this.okButton = new System.Windows.Forms.Button(); + this.tableLayoutPanel.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.logoPictureBox)).BeginInit(); + this.SuspendLayout(); + // + // tableLayoutPanel + // + this.tableLayoutPanel.ColumnCount = 2; + this.tableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 33F)); + this.tableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 67F)); + this.tableLayoutPanel.Controls.Add(this.logoPictureBox, 0, 0); + this.tableLayoutPanel.Controls.Add(this.labelProductName, 1, 0); + this.tableLayoutPanel.Controls.Add(this.labelVersion, 1, 1); + this.tableLayoutPanel.Controls.Add(this.labelCopyright, 1, 2); + this.tableLayoutPanel.Controls.Add(this.labelCompanyName, 1, 3); + this.tableLayoutPanel.Controls.Add(this.textBoxDescription, 1, 4); + this.tableLayoutPanel.Controls.Add(this.okButton, 1, 5); + this.tableLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill; + this.tableLayoutPanel.Location = new System.Drawing.Point(9, 9); + this.tableLayoutPanel.Name = "tableLayoutPanel"; + this.tableLayoutPanel.RowCount = 6; + this.tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F)); + this.tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel.Size = new System.Drawing.Size(417, 265); + this.tableLayoutPanel.TabIndex = 0; + // + // logoPictureBox + // + this.logoPictureBox.Image = global::RetroGOG.Properties.Resources.retroGOG1; + this.logoPictureBox.Location = new System.Drawing.Point(3, 3); + this.logoPictureBox.Name = "logoPictureBox"; + this.tableLayoutPanel.SetRowSpan(this.logoPictureBox, 6); + this.logoPictureBox.Size = new System.Drawing.Size(131, 136); + this.logoPictureBox.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.logoPictureBox.TabIndex = 12; + this.logoPictureBox.TabStop = false; + // + // labelProductName + // + this.labelProductName.Dock = System.Windows.Forms.DockStyle.Fill; + this.labelProductName.Location = new System.Drawing.Point(143, 0); + this.labelProductName.Margin = new System.Windows.Forms.Padding(6, 0, 3, 0); + this.labelProductName.MaximumSize = new System.Drawing.Size(0, 17); + this.labelProductName.Name = "labelProductName"; + this.labelProductName.Size = new System.Drawing.Size(271, 17); + this.labelProductName.TabIndex = 19; + this.labelProductName.Text = "RetroGOG Installation Wizard"; + this.labelProductName.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + // + // labelVersion + // + this.labelVersion.Dock = System.Windows.Forms.DockStyle.Fill; + this.labelVersion.Location = new System.Drawing.Point(143, 26); + this.labelVersion.Margin = new System.Windows.Forms.Padding(6, 0, 3, 0); + this.labelVersion.MaximumSize = new System.Drawing.Size(0, 17); + this.labelVersion.Name = "labelVersion"; + this.labelVersion.Size = new System.Drawing.Size(271, 17); + this.labelVersion.TabIndex = 0; + this.labelVersion.Text = "Version"; + this.labelVersion.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + // + // labelCopyright + // + this.labelCopyright.Dock = System.Windows.Forms.DockStyle.Fill; + this.labelCopyright.Location = new System.Drawing.Point(143, 52); + this.labelCopyright.Margin = new System.Windows.Forms.Padding(6, 0, 3, 0); + this.labelCopyright.MaximumSize = new System.Drawing.Size(0, 17); + this.labelCopyright.Name = "labelCopyright"; + this.labelCopyright.Size = new System.Drawing.Size(271, 17); + this.labelCopyright.TabIndex = 21; + this.labelCopyright.Text = "Copyright"; + this.labelCopyright.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + // + // labelCompanyName + // + this.labelCompanyName.Dock = System.Windows.Forms.DockStyle.Fill; + this.labelCompanyName.Location = new System.Drawing.Point(143, 78); + this.labelCompanyName.Margin = new System.Windows.Forms.Padding(6, 0, 3, 0); + this.labelCompanyName.MaximumSize = new System.Drawing.Size(0, 17); + this.labelCompanyName.Name = "labelCompanyName"; + this.labelCompanyName.Size = new System.Drawing.Size(271, 17); + this.labelCompanyName.TabIndex = 22; + this.labelCompanyName.Text = "Company Name"; + this.labelCompanyName.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + // + // textBoxDescription + // + this.textBoxDescription.Dock = System.Windows.Forms.DockStyle.Fill; + this.textBoxDescription.Location = new System.Drawing.Point(143, 107); + this.textBoxDescription.Margin = new System.Windows.Forms.Padding(6, 3, 3, 3); + this.textBoxDescription.Multiline = true; + this.textBoxDescription.Name = "textBoxDescription"; + this.textBoxDescription.ReadOnly = true; + this.textBoxDescription.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.textBoxDescription.Size = new System.Drawing.Size(271, 126); + this.textBoxDescription.TabIndex = 23; + this.textBoxDescription.TabStop = false; + this.textBoxDescription.Text = "Description"; + // + // okButton + // + this.okButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.okButton.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.okButton.Location = new System.Drawing.Point(339, 239); + this.okButton.Name = "okButton"; + this.okButton.Size = new System.Drawing.Size(75, 23); + this.okButton.TabIndex = 24; + this.okButton.Text = "&OK"; + this.okButton.Click += new System.EventHandler(this.okButton_Click); + // + // frmAbout + // + this.AcceptButton = this.okButton; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(435, 283); + this.Controls.Add(this.tableLayoutPanel); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + this.MaximizeBox = false; + this.MinimizeBox = false; + this.Name = "frmAbout"; + this.Padding = new System.Windows.Forms.Padding(9); + this.ShowIcon = false; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "About RetroGOG"; + this.Load += new System.EventHandler(this.frmAbout_Load); + this.tableLayoutPanel.ResumeLayout(false); + this.tableLayoutPanel.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)(this.logoPictureBox)).EndInit(); + this.ResumeLayout(false); + + } + + #endregion + + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel; + private System.Windows.Forms.PictureBox logoPictureBox; + private System.Windows.Forms.Label labelProductName; + private System.Windows.Forms.Label labelVersion; + private System.Windows.Forms.Label labelCopyright; + private System.Windows.Forms.Label labelCompanyName; + private System.Windows.Forms.TextBox textBoxDescription; + private System.Windows.Forms.Button okButton; + } +} diff --git a/src/RetroGOG/frmAbout.cs b/src/RetroGOG/frmAbout.cs new file mode 100644 index 0000000..13779a3 --- /dev/null +++ b/src/RetroGOG/frmAbout.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace RetroGOG +{ + partial class frmAbout : Form + { + public frmAbout() + { + InitializeComponent(); + this.Text = String.Format("About {0}", AssemblyTitle); + this.labelVersion.Text = String.Format("Version {0}", AssemblyVersion); + this.labelCopyright.Text = AssemblyCopyright; + this.labelCompanyName.Text = AssemblyCompany; + this.textBoxDescription.Text = AssemblyDescription; + } + + #region Assembly Attribute Accessors + + public string AssemblyTitle + { + get + { + object[] attributes = Assembly.GetExecutingAssembly().GetCustomAttributes(typeof(AssemblyTitleAttribute), false); + if (attributes.Length > 0) + { + AssemblyTitleAttribute titleAttribute = (AssemblyTitleAttribute)attributes[0]; + if (titleAttribute.Title != "") + { + return titleAttribute.Title; + } + } + return System.IO.Path.GetFileNameWithoutExtension(Assembly.GetExecutingAssembly().CodeBase); + } + } + + public string AssemblyVersion + { + get + { + return Assembly.GetExecutingAssembly().GetName().Version.ToString(); + } + } + + public string AssemblyDescription + { + get + { + object[] attributes = Assembly.GetExecutingAssembly().GetCustomAttributes(typeof(AssemblyDescriptionAttribute), false); + if (attributes.Length == 0) + { + return ""; + } + return ((AssemblyDescriptionAttribute)attributes[0]).Description; + } + } + + public string AssemblyProduct + { + get + { + object[] attributes = Assembly.GetExecutingAssembly().GetCustomAttributes(typeof(AssemblyProductAttribute), false); + if (attributes.Length == 0) + { + return ""; + } + return ((AssemblyProductAttribute)attributes[0]).Product; + } + } + + public string AssemblyCopyright + { + get + { + object[] attributes = Assembly.GetExecutingAssembly().GetCustomAttributes(typeof(AssemblyCopyrightAttribute), false); + if (attributes.Length == 0) + { + return ""; + } + return ((AssemblyCopyrightAttribute)attributes[0]).Copyright; + } + } + + public string AssemblyCompany + { + get + { + object[] attributes = Assembly.GetExecutingAssembly().GetCustomAttributes(typeof(AssemblyCompanyAttribute), false); + if (attributes.Length == 0) + { + return ""; + } + return ((AssemblyCompanyAttribute)attributes[0]).Company; + } + } + #endregion + + private void okButton_Click(object sender, EventArgs e) + { + this.Close(); + } + + private void frmAbout_Load(object sender, EventArgs e) + { + + } + + } +} diff --git a/src/RetroGOG/frmAbout.resx b/src/RetroGOG/frmAbout.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/src/RetroGOG/frmAbout.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/src/RetroGOG/frmComplete.Designer.cs b/src/RetroGOG/frmComplete.Designer.cs new file mode 100644 index 0000000..8fd39c6 --- /dev/null +++ b/src/RetroGOG/frmComplete.Designer.cs @@ -0,0 +1,177 @@ +namespace RetroGOG +{ + partial class frmComplete + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(frmComplete)); + this.btnBack = new System.Windows.Forms.Button(); + this.btnAbout = new System.Windows.Forms.Button(); + this.btnCancel = new System.Windows.Forms.Button(); + this.lblExplain2 = new System.Windows.Forms.Label(); + this.lblExplain = new System.Windows.Forms.Label(); + this.lblWelcom = new System.Windows.Forms.Label(); + this.pictureBox1 = new System.Windows.Forms.PictureBox(); + this.pictureBox2 = new System.Windows.Forms.PictureBox(); + this.label1 = new System.Windows.Forms.Label(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox2)).BeginInit(); + this.SuspendLayout(); + // + // btnBack + // + this.btnBack.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.btnBack.Location = new System.Drawing.Point(93, 343); + this.btnBack.Name = "btnBack"; + this.btnBack.Size = new System.Drawing.Size(75, 23); + this.btnBack.TabIndex = 13; + this.btnBack.Text = "< < &Back"; + this.btnBack.UseVisualStyleBackColor = true; + this.btnBack.Click += new System.EventHandler(this.btnBack_Click); + // + // btnAbout + // + this.btnAbout.Location = new System.Drawing.Point(12, 343); + this.btnAbout.Name = "btnAbout"; + this.btnAbout.Size = new System.Drawing.Size(75, 23); + this.btnAbout.TabIndex = 12; + this.btnAbout.Text = "&About"; + this.btnAbout.UseVisualStyleBackColor = true; + this.btnAbout.Click += new System.EventHandler(this.btnAbout_Click); + // + // btnCancel + // + this.btnCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.btnCancel.Location = new System.Drawing.Point(543, 343); + this.btnCancel.Name = "btnCancel"; + this.btnCancel.Size = new System.Drawing.Size(75, 23); + this.btnCancel.TabIndex = 11; + this.btnCancel.Text = "&Finish"; + this.btnCancel.UseVisualStyleBackColor = true; + this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click); + // + // lblExplain2 + // + this.lblExplain2.AutoSize = true; + this.lblExplain2.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblExplain2.Location = new System.Drawing.Point(13, 63); + this.lblExplain2.Name = "lblExplain2"; + this.lblExplain2.Size = new System.Drawing.Size(430, 18); + this.lblExplain2.TabIndex = 16; + this.lblExplain2.Text = "Your configured plugins should now be available in GOG Galaxy."; + // + // lblExplain + // + this.lblExplain.AutoSize = true; + this.lblExplain.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblExplain.Location = new System.Drawing.Point(13, 45); + this.lblExplain.Name = "lblExplain"; + this.lblExplain.Size = new System.Drawing.Size(199, 18); + this.lblExplain.TabIndex = 15; + this.lblExplain.Text = "This wizard is now complete."; + // + // lblWelcom + // + this.lblWelcom.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.lblWelcom.Font = new System.Drawing.Font("Microsoft Sans Serif", 14.25F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblWelcom.Location = new System.Drawing.Point(-1, 9); + this.lblWelcom.Name = "lblWelcom"; + this.lblWelcom.Size = new System.Drawing.Size(630, 24); + this.lblWelcom.TabIndex = 14; + this.lblWelcom.Text = "Thank you for using the RetroGOG Installation Wizard"; + this.lblWelcom.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // pictureBox1 + // + this.pictureBox1.Image = global::RetroGOG.Properties.Resources.step_1; + this.pictureBox1.InitialImage = global::RetroGOG.Properties.Resources.step_1; + this.pictureBox1.Location = new System.Drawing.Point(12, 122); + this.pictureBox1.Name = "pictureBox1"; + this.pictureBox1.Size = new System.Drawing.Size(304, 215); + this.pictureBox1.TabIndex = 17; + this.pictureBox1.TabStop = false; + // + // pictureBox2 + // + this.pictureBox2.Image = global::RetroGOG.Properties.Resources.step_2; + this.pictureBox2.Location = new System.Drawing.Point(64, 122); + this.pictureBox2.Name = "pictureBox2"; + this.pictureBox2.Size = new System.Drawing.Size(554, 215); + this.pictureBox2.TabIndex = 18; + this.pictureBox2.TabStop = false; + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.label1.Location = new System.Drawing.Point(16, 85); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(363, 18); + this.label1.TabIndex = 19; + this.label1.Text = "If GOG Galaxy 2.0 is already running, please re-start it."; + // + // frmComplete + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(630, 378); + this.Controls.Add(this.label1); + this.Controls.Add(this.pictureBox1); + this.Controls.Add(this.pictureBox2); + this.Controls.Add(this.lblExplain2); + this.Controls.Add(this.lblExplain); + this.Controls.Add(this.lblWelcom); + this.Controls.Add(this.btnBack); + this.Controls.Add(this.btnAbout); + this.Controls.Add(this.btnCancel); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; + this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon"))); + this.MaximizeBox = false; + this.Name = "frmComplete"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "RetroGOG"; + ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox2)).EndInit(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Button btnBack; + private System.Windows.Forms.Button btnAbout; + private System.Windows.Forms.Button btnCancel; + private System.Windows.Forms.Label lblExplain2; + private System.Windows.Forms.Label lblExplain; + private System.Windows.Forms.Label lblWelcom; + private System.Windows.Forms.PictureBox pictureBox1; + private System.Windows.Forms.PictureBox pictureBox2; + private System.Windows.Forms.Label label1; + } +} \ No newline at end of file diff --git a/src/RetroGOG/frmComplete.cs b/src/RetroGOG/frmComplete.cs new file mode 100644 index 0000000..3b803c3 --- /dev/null +++ b/src/RetroGOG/frmComplete.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace RetroGOG +{ + public partial class frmComplete : Form + { + public frmComplete() + { + InitializeComponent(); + } + + private void btnAbout_Click(object sender, EventArgs e) + { + Form frmAbout = new frmAbout(); + frmAbout.Show(); + } + + private void btnBack_Click(object sender, EventArgs e) + { + this.Hide(); + Form frmPluginSelect = new frmPluginSelect(); + frmPluginSelect.Closed += (s, args) => this.Close(); + frmPluginSelect.Show(); + } + + private void btnCancel_Click(object sender, EventArgs e) + { + this.Close(); + } + } +} diff --git a/src/RetroGOG/frmComplete.resx b/src/RetroGOG/frmComplete.resx new file mode 100644 index 0000000..accb675 --- /dev/null +++ b/src/RetroGOG/frmComplete.resx @@ -0,0 +1,1889 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + 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 + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAICAgAFPT08sPz8/bjMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4A+Pj5xTU1NMnR0dAIA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAImJiQBHR0dEOTk5xzQ0NP4zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk50UVFRVGOjo4AAAAAAAAAAAAAAAAAAAAAAAAAAABs + bGwEPj4+jzQ0NP4zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/z4+PqFdXV0IAAAAAAAAAAAAAAAAZmZmAT09PZ8zMzP/MzMz/zMzM/9iYmL/r6+v/9jY2P/k + 5OT/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5OTk/9ra2v+zs7P/aWlp/zMzM/8zMzP/MzMz/zw8PLJ6enoEAAAAAAAAAABC + QkJqMzMz/zMzM/88PDz/tbW1//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////wcHB/0FBQf8zMzP/MzMz/z8/P34AAAAAWlpaEjY2Nu4zMzP/NjY2/8XFxf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////0tLS/zo6Ov8zMzP/NTU19lFRUR8/ + Pz9xMzMz/zMzM/+Kior///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////m5ub/zMzM/8zMzP/Pj4+hjo6OsAzMzP/NTU1/+Xl5f////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////w8PD/Ozs7/zMzM/85 + OTnVQUFB+zMzM/9TU1P///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////9jY2P/MzMz/zY2Nv01NTX/MzMz/2lpaf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3p6ev8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ////////////////////////////////////uLi4/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/vb29/+Li4v////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////9zc3P+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7e3t//MzMz/9/f3//////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NTU1/2lpaf/h4eH///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9DQ0P/p6en//7+/v////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0NDQ//c3Nz///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/iYmJ//////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1tbW//+ + /v7///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////52dnf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/wsLC//////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/83Nzf////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9qamr/////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/n5+f//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/z8/P//8/Pz///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//f39////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////9hYWH/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1FRUf80NDT/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5SUlP////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////sLCw/1tbW/9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9aWlr/Pz8//zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////8DAwP81NTX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////z8/P/Z2dn/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////2hoaP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+UlJT///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////Nzc3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////9zc3P8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////94eHj/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/lJSU//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/2 + 9vb///////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////c + 3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + ///////////////////////////////////////////////x8fH/vLy8/6Wlpf+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo//9/f3/////////////////eHh4/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////////////////////////////////r6+v/I + yMj/qKio/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/o6Oj/6enp//FxcX/+Pj4//////////////////////////////////////// + /////////////////////v7+/9XV1f+tra3/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/0dHR/////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85 + OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v////////////////////////////////////////////////////////////////////////////9 + /f3/nJyc/z09Pf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NDQ0//v7+/////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////// + //////////////////////////////++vr7/SkpK/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9G + Rkb/t7e3/////////////////////////////////////////////////9vb2/9eXl7/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+ZmZn///////////// + ////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + /////////////////////////////////////v7+/4SEhP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/80NDT/+/v7/////////////////3h4eP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT/////////////////////////////////s7Oz/zU1Nf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/80NDT/q6ur//////////////////////// + ///////////////a2tr/QEBA/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5mZmf/////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/OTk5//b29v///////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr///////////////////////////////////////////////////////////////////////C + wsL/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zQ0NP/7+/v/////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP// + /////////////////////////+rq6v89PT3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/86Ojr/5OTk/////////////////////////////f39/1tbW/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ//////// + /////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////25ubv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0//v7+/////////////////94 + eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////oKCg/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+Wlpb///////////// + ///////////////Q0ND/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+ZmZn/////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zk5Of/29vb///////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////RERE/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/80NDT/+/v7/////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+U + lJT///////////////////////////92dnb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2xsbP///////////////////////////6enp/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5mZmf// + ///////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP/7+/v///////////// + ////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ/////////////////9zc3P8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/85OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/R0dH/2pqav9ra2v/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/a2tr//z8/P////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/89 + PT3/aGho/21tbf9tbW3/bW1t/21tbf9tbW3/bW1t/21tbf9tbW3/bW1t/21tbf9paWn/Pj4+/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zY2Nv9gYGD/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/+1 + tbX/////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/2pqav/39/f///////////////////////////////////////////////////////////// + /////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/SEhI/+bm5v////////////////////////////////// + ///////////////////////////////q6ur/TU1N/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/82Njb/x8fH//////// + ///////////////////////////////////////////////////////////////c3Nz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//b29v///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/xcXF//////////////////////// + ////////////////////////////////////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5SUlP///////////////////////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+T + k5P///////////////////////////////////////////////////////////////////////////+a + mpr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi////////////////////////////nZ2d/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2FhYf////////////////////////////////////////////////// + /////////////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + /////////////////////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM//R0dH///////////////////////////////////////////////////////////// + //////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////////////////////// + ////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////////////////////// + /////////////////////////////////////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9i + YmL///////////////////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////// + ////////////////////////////////////////////////////////////////////3Nzc/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9HR0f////////////////// + /////////////////////////////////////////////////////////3h4eP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+UlJT///////////////////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/n5+f//////////////////////////////////////////////////////////////////////// + ////pqam/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv///////////////////////////52dnf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3///////////////////////////////////////////// + ///////////////////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////86Ojr/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/0dHR//////////////////////////////////////////////////////// + ////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////// + /////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+fn5////////////////////////////// + //////////////////////////////////////////////+mpqb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/YmJi////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/21tbf// + /////////////////////////////////////////////////////////////////////////9zc3P8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + /////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R0dH///////////// + //////////////////////////////////////////////////////////////94eHj/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5+fn/////////////////////////////////////////////////////////////////// + /////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+d + nZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////////////////////////////////////// + ////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/2 + 9vb///////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ////////////////////////////////////////////////////////////////////Ojo6/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/9HR0f////////////////////////////////////////////////// + /////////////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////// + //////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/n5+f//////////////////////// + ////////////////////////////////////////////////////pqam/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/2JiYv///////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9t + bW3////////////////////////////////////////////////////////////////////////////c + 3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/0dHR//////// + ////////////////////////////////////////////////////////////////////eHh4/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+fn5////////////////////////////////////////////////////////////// + //////////////+mpqb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////////////////////// + ////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/21tbf////////////////////////////////// + /////////////////////////////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85 + OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v//////////////////////////////////////////////////////////////////////zo6Ov8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R0dH///////////////////////////////////////////// + //////////////////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////// + ////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////// + /////////////////////////////////////////////////////////6ampv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/bW1t//////////////////////////////////////////////////////////////////////// + ////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9HR0f// + /////////////////////////////////////////////////////////////////////////3h4eP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9sbGz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/n5+f//////////////////////////////////////////////////////// + ////////////////////pqam/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv////////////////// + /////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3///////////////////////////// + ///////////////////////////////////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/OTk5//b29v///////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/0dHR//////////////////////////////////////// + ////////////////////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP// + /////////////////////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+fn5////////////// + //////////////////////////////////////////////////////////////+mpqb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/YmJi////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/21tbf////////////////////////////////////////////////////////////////// + /////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R + 0dH///////////////////////////////////////////////////////////////////////////94 + eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////bGxs/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////////////////////////////////////// + /////////////////////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////// + //////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////////////////////// + ////////////////////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zk5Of/29vb///////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9DQ0P////////////////////////////////// + /////////////////////////////////////////3Z2dv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+U + lJT///////////////////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/np6e//////// + ////////////////////////////////////////////////////////////////////paWl/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2JiYv///////////////////////////52dnf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + ///////////////b29v/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/p6en///////////////////////////////////////////////////////////////////////8 + /Pz/UVFR/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/91dXX///////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/0lJSf/5+fn///////////// + /////////////////////////////////////////////////////////7Kysv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/85OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/89PT3/qamp/9PT0//U1NT/1NTU/9TU1P/U + 1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/y8vL/3R0dP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP+N + jY3/0NDQ/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/R0dH/k5OT/zU1Nf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/21tbf/Jycn/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U + 1NT/1NTU/9PT0/+urq7/QEBA/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//b29v///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////88PDz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5aWlv///////////////////////////25ubv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ZGRk////////////////////////////n5+f/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr/+Pj4//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + /////////////////////////////////////////////////////////1NTU/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////////////////////// + ////hYWF/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/97 + e3v///////////////////////////+2trb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0pKSv////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ////////////////////kJCQ/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zY2Nv/n5+f////////////////////////////CwsL/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/7m5uf///////////////////////////+3t7f86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/h4eH//////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + ///////////////////////////////////////////////////////////////s7Oz/RUVF/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/jIyM//////////////////////// + //////////39/f9lZWX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9e + Xl7/+/v7/////////////////////////////////5WVlf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0FBQf/n5+f///////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ///////////////////////////////S0tL/RERE/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/3d3d//5+fn//////////////////////////////////////+3t7f9cXFz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/V1dX/+np6f////////////////////////////////// + /////Pz8/39/f/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9CQkL/zMzM//////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ///////////////////////////////////////////////////////////////////////////////s + 7Oz/kJCQ/1JSUv89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/QkJC/2lpaf+6urr//v7+//////////////////////// + //////////////////////////n5+f+mpqb/Xl5e/z8/P/89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf8/Pz//XFxc/6Kiov/3 + 9/f//////////////////////////////////////////////////v7+/7+/v/9ra2v/Q0ND/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf9RUVH/jY2N/+np6f////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////39/f/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/+ + /v7///////////////////////////////////////////////////////////////////////////// + /////v7+//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//7+/v////////////////////////////////////////////////// + /////////////////////////////////////Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//f39//////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ///////////////////////////////IyMj/gICA/3Z2dv+lpaX/+Pj4//////////////////////// + /////////////////////////////////////////8rKyv+BgYH/dnZ2/6Ghof/19fX///////////// + ///////////////////////////////+/v7/4eHh/66urv+MjIz/eXl5/3Jycv94eHj/i4uL/66urv/k + 5OT///////////////////////////////////////////////////////////////////////z8/P/p + 6en/2NjY/9jY2P/k5OT/+Pj4////////////////////////////3t7e/4iIiP92dnb/pKSk//j4+P// + ///////////////////////////////////////////////////////////////////////////////x + 8fH/vLy8/5OTk/97e3v/c3Nz/3h4eP+Li4v/sLCw/+Xl5f////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ////////////////////////////////////////////////////////////////////t7e3/zU1Nf8z + MzP/MzMz/zMzM/9qamr//v7+//////////////////////////////////////////////////////+n + p6f/NTU1/zMzM/8zMzP/MzMz/15eXv/6+vr/////////////////////////////////wcHB/19fX/80 + NDT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zU1Nf9lZWX/yMjI//////////////////////// + //////////////////////////v7+/+dnZ3/SUlJ/zMzM/8zMzP/MzMz/zMzM/88PDz/b29v/9nZ2f// + /////////+Li4v8/Pz//MzMz/zMzM/8zMzP/cnJy//7+/v////////////////////////////////// + ///////////////////////////////o6Oj/hISE/zs7O/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NTU1/2lpaf/Pz8////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////9VVVX/MzMz/zMzM/8zMzP/MzMz/zMzM//Q0ND///////////// + ////////////////////////////////////0dHR/zc3N/8zMzP/MzMz/zMzM/8zMzP/MzMz/8fHx/// + ////////////////////9/f3/3t7e/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/80NDT/hoaG//r6+v//////////////////////////////////////hISE/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/RERE//f39///////kZGR/zMzM/8zMzP/MzMz/zMzM/8z + MzP/3d3d////////////////////////////////////////////////////////////vLy8/z8/P/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP+Pj4///Pz8//////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v/////////////////////////////////////////////////////////////////+/v7/zc3N/8z + MzP/MzMz/zMzM/8zMzP/MzMz/6+vr/////////////////////////////////////////////j4+P9U + VFT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/19fX//////////////////r6+v9sbGz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/f39///////// + /////////////////////////+bm5v82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/83 + Nzf/8PDw//////91dXX/MzMz/zMzM/8zMzP/MzMz/zMzM//CwsL///////////////////////////// + /////////////////////////7W1tf82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9/f3///v7+//////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ///////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////// + ////////////////////////////////////lJSU/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1hYWP/9 + /f3/////////////////lJSU/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/0xMTP+Hh4f/mZmZ/4eHh/9Q + UFD/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/yMjI////////////////////////////ubm5/zMzM/8z + MzP/MzMz/zMzM/82Njb/iIiI/5ubm/9/f3//ampq/7W1tf///////////3Nzc/8zMzP/MzMz/zMzM/8z + MzP/MzMz/7+/v//////////////////////////////////////////////////a2tr/OTk5/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0RERP9aWlr/S0tL/zQ0NP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+o + qKj//////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////j4+P82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz//////////////////////////////////////9jY2P84 + ODj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/t7e3/////////////////+bm5v85OTn/MzMz/zMzM/8z + MzP/MzMz/zMzM/+Ojo7/+fn5//////////////////z8/P+urq7/Pj4+/zMzM/8zMzP/MzMz/zMzM/+K + ior///////////////////////////+hoaH/MzMz/zMzM/8zMzP/MzMz/3d3d/////////////////// + ////////////////////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/v7+///////////////////////// + /////////////////////v7+/2lpaf8zMzP/MzMz/zMzM/8zMzP/MzMz/0JCQv+9vb3/+/v7///////+ + /v7/1tbW/1lZWf8zMzP/MzMz/zMzM/8zMzP/MzMz/0BAQP/w8PD///////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + ////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6ysrP// + ///////////////////////////////7+/v/XV1d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/15eXv/9 + /f3/////////////////l5eX/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//7+/v////////////////// + ///////////////d3d3/UFBQ/zMzM/8zMzP/MzMz/6Ojo////////////////////////////5eXl/8z + MzP/MzMz/zMzM/8zMzP/lZWV//////////////////////////////////////9zc3P/MzMz/zMzM/8z + MzP/MzMz/zMzM/+/v7/////////////////////////////////////////////b29v/NDQ0/zMzM/8z + MzP/MzMz/zMzM/82Njb/z8/P////////////////////////////8PDw/0tLS/8zMzP/MzMz/zMzM/8z + MzP/MzMz/6Wlpf////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra//////////////////////////////////////////////////////////////////4 + +Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys/////////////////////////////////6CgoP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/84ODj/1tbW//////////////////////9cXFz/MzMz/zMzM/8z + MzP/MzMz/zMzM//Kysr////////////////////////////////////////////u7u7/hISE/2dnZ/+X + l5f/+vr6////////////////////////////lZWV/zMzM/8zMzP/MzMz/zMzM/+ampr///////////// + /////////////////////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/7+/v/////////////////// + /////////////////////////5ycnP8zMzP/MzMz/zMzM/8zMzP/MzMz/3Nzc/////////////////// + ////////////////////q6ur/zMzM/8zMzP/MzMz/zMzM/8zMzP/ZmZm//////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+s + rKz////////////////////////////a2tr/Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5qamv// + ////////////////////+fn5/zw8PP8zMzP/MzMz/zMzM/8zMzP/NDQ0/7S0tP++vr7/vr6+/76+vv++ + vr7/vr6+/76+vv++vr7/vr6+/7+/v//Ly8v/6urq//////////////////////////////////////+V + lZX/MzMz/zMzM/8zMzP/MzMz/5qamv//////////////////////////////////////c3Nz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/vr6+////////////////////////////////////////////dHR0/zMzM/8z + MzP/MzMz/zMzM/8zMzP/sbGx///////////////////////////////////////p6en/MzMz/zMzM/8z + MzP/MzMz/zMzM/9AQED//Pz8////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + ////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6ysrP//////////////////////7+/v/1JSUv8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3//f39///////////////////////u7u7/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/81 + NTX/ZmZm/+rq6v///////////////////////////5WVlf8zMzP/MzMz/zMzM/8zMzP/mpqa//////// + //////////////////////////////9zc3P/MzMz/zMzM/8zMzP/MzMz/zMzM/+4uLj///////////// + //////////////////////////////9eXl7/MzMz/zMzM/8zMzP/MzMz/zMzM//Pz8////////////// + //////////////////////////////87Ozv/MzMz/zMzM/8zMzP/MzMz/zY2Nv/w8PD///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ///////////////////////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8z + MzP/rKys/////////////////+Dg4P9YWFj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/W1tb//Pz8/// + /////////////////////////+np6f8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/fHx8//////////////////////// + ////lZWV/zMzM/8zMzP/MzMz/zMzM/+ampr//////////////////////////////////////3Nzc/8z + MzP/MzMz/zMzM/8zMzP/MzMz/62trf///////////////////////////////////////////1lZWf8z + MzP/MzMz/zMzM/8zMzP/MzMz/9bW1v///////////////////////////////////////////0FBQf8z + MzP/MzMz/zMzM/8zMzP/NDQ0/+zs7P///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/9mZmb/iYmJ/4KCgv9lZWX/NjY2/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2JiYv/v7+//////////////////////////////////8/Pz/zQ0NP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9NTU3///////////////////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv// + ////////////////////////////////////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ//////// + ////////////////////////////////////ZGRk/zMzM/8zMzP/MzMz/zMzM/8zMzP/x8fH//////// + ///////////////////////////////7+/v/NjY2/zMzM/8zMzP/MzMz/zMzM/84ODj/9PT0//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + ////////////////////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/83Nzf/mpqa/+np6f// + ///////////////////////////////9/f3/RkZG/zMzM/8zMzP/MzMz/zMzM/8zMzP/m5ub/6urq/+r + q6v/q6ur/6urq/+rq6v/q6ur/6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/01NTf////////////////// + /////////5WVlf8zMzP/MzMz/zMzM/8zMzP/mpqa//////////////////////////////////////9z + c3P/MzMz/zMzM/8zMzP/MzMz/zMzM/92dnb///////////////////////////////////////////+A + gID/MzMz/zMzM/8zMzP/MzMz/zMzM/+enp7//////////////////////////////////////9fX1/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0pKSv////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ///////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5/4qKiv/09PT///////////////////////////9y + cnL/MzMz/zMzM/8zMzP/MzMz/zMzM/+zs7P/////////////////////////////////ysrK/zMzM/8z + MzP/MzMz/zMzM/8zMzP/aWlp////////////////////////////lZWV/zMzM/8zMzP/MzMz/zMzM/+a + mpr//////////////////////////////////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/0NDQ//5 + +fn//////////////////////////////////////7CwsP8zMzP/MzMz/zMzM/8zMzP/MzMz/1VVVf/8 + /Pz/////////////////////////////////ioqK/zMzM/8zMzP/MzMz/zMzM/8zMzP/enp6//////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////j4+P82Njb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/1dXV//r6+v//////////////////////7a2tv8zMzP/MzMz/zMzM/8zMzP/MzMz/1FRUf/z + 8/P///////////////////////v7+/9mZmb/MzMz/zMzM/8zMzP/MzMz/zMzM/+jo6P///////////// + //////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv////////////////////////////////// + ////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5ycnP/////////////////z8/P/0dHR/+7u7v// + ////7+/v/zs7O/8zMzP/MzMz/zMzM/8zMzP/MzMz/5iYmP/+/v7//////////////////////8fHx/84 + ODj/MzMz/zMzM/8zMzP/MzMz/zMzM//BwcH///////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2VlZf/+/v7///////////// + ////+fn5/05OTv8zMzP/MzMz/zMzM/8zMzP/MzMz/1lZWf/FxcX/8vLy//Pz8//Ozs7/aGho/zMzM/8z + MzP/MzMz/zMzM/8zMzP/Pz8//+/v7////////////////////////////5WVlf8zMzP/MzMz/zMzM/8z + MzP/mpqa//////////////////////////////////////9zc3P/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NDQ0/2RkZP99fX3/YmJi/zk5Of8zMzP/PDw8/7y8vP//////jIyM/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/3Jycv/CwsL/29vb/8zMzP+NjY3/ODg4/zMzM/8zMzP/MzMz/zMzM/8zMzP/W1tb//39/f// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ///////////////////////////////////////////////////////////////4+Pj/NjY2/zMzM/8z + MzP/MzMz/zMzM/8zMzP/pKSk//Ly8v/y8vL/8vLy//Ly8v/u7u7/4eHh/8XFxf+RkZH/Pz8//zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/8LCwv//////////////////////xMTE/zU1Nf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/82Njb/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+oqKj///////////// + ////9vb2/3Nzc/88PDz/NjY2/zMzM/8zMzP/MzMz/zMzM/82Njb/OTk5/0ZGRv92dnb/7e3t//////// + /////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/RUVF//v7+//z8/P/UFBQ/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zs7O//W1tb///////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz///////////// + ///////////////////////////////c3Nz/Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/f39///////// + ////////////////////paWl/zQ0NP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/hoaG//7+/v////////////////+2trb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+goKD/////////////////fn5+/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/+Pj4///////f39//SUlJ/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr/v7+///////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v/////////////////////////////////////////////////////////////////+Pj4/zY2Nv8z + MzP/MzMz/zMzM/8zMzP/MzMz/6ysrP////////////////////////////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/9cXFz/////////////////////////////////vLy8/0ZGRv8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Ozs7/6SkpP/+/v7///////////// + /////////9zc3P88PDz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NjY2/8nJyf// + //////////////+tra3/MzMz/zMzM/8zMzP/MzMz/0pKSv9dXV3/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/4eHh//////////////////q6ur/bW1t/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/VlZW/9TU1P////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ///////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////// + /////////////////////////////////////////3BwcP8zMzP/MzMz/zMzM/8zMzP/MzMz/1NTU/// + ////////////////////////////////////8vLy/6Kiov9aWlr/NTU1/zMzM/8zMzP/MzMz/zMzM/80 + NDT/SUlJ/46Ojv/m5ub//////////////////////////////////////+fn5/+4uLj/cHBw/zMzM/8z + MzP/MzMz/zMzM/9zc3P/srKy/7S0tP/d3d3///////////////////////j4+P9kZGT/MzMz/zMzM/84 + ODj/urq6//Dw8P9ycnL/NDQ0/zMzM/8zMzP/MzMz/01NTf+oqKj//Pz8///////////////////////+ + /v7/ysrK/3d3d/8+Pj7/MzMz/zMzM/8zMzP/MzMz/zMzM/84ODj/Z2dn/7W1tf/6+vr///////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////j4+P82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz////////////////////////////////////////////r + 6+v/Pz8//zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////////////////////////////////////// + ///////////////w8PD/0dHR/8HBwf++vr7/ysrK/+Xl5f/+/v7///////////////////////////// + //////////////////////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv////////////////// + //////////////////////////z8/P/Pz8//wMDA/+jo6P/////////////////k5OT/wsLC/8HBwf/d + 3d3//v7+//////////////////////////////////////////////////v7+//d3d3/xsbG/729vf/D + w8P/19fX//b29v////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + ////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6urq//9 + /f3//f39//39/f/9/f3//f39//v7+//z8/P/xMTE/1VVVf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+N + jY3///////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////5WVlf8z + MzP/MzMz/zMzM/8zMzP/mpqa//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra//////////////////////////////////////////////////////////////////4 + +Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/Pj4+/0VFRf9FRUX/RUVF/0VFRf9ERET/Pj4+/zQ0NP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0/9jY2P////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////lpaW/zMzM/8zMzP/MzMz/zMzM/+ampr///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////r6+v82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+C + goL///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////+c + nJz/MzMz/zMzM/8zMzP/MzMz/6Kiov////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////0tLS/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ampq//f39/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/zMzM/8zMzP/xMTE//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////oaGh/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/PT09/5iYmP/7 + +/v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////f39/4aGhv85OTn/Ozs7/4+Pj//+/v7///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + ///////////////9/f3/sLCw/2dnZ/9PT0//TU1N/01NTf9NTU3/TU1N/01NTf9NTU3/TU1N/01NTf9N + TU3/T09P/1ZWVv9lZWX/gICA/7CwsP/w8PD///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////X19f/29vb///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/Ozs7/zMzM/9fX1////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////9wcHD/MzMz/zMzM/89PT3cMzMz/z4+Pv/39/f///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////Pz8/0pKSv8z + MzP/OTk57Dw8PJUzMzP/MzMz/7W1tf////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////Gxsb/MzMz/zMzM/87OzuqS0tLNjQ0NP0zMzP/TExM//Hx8f// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////9/f3/1dXV/8z + MzP/MzMz/0VFRUmNjY0APDw8rTMzM/8zMzP/ZGRk//Hx8f////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////b29v9wcHD/MzMz/zMzM/86OjrAgYGBAgAAAABTU1MYNzc34zMzM/8z + MzP/TExM/7a2tv/39/f///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////r6+v++vr7/U1NT/zMzM/8z + MzP/NjY27E1NTSMAAAAAAAAAAAAAAABKSkoqNzc34jMzM/8zMzP/MzMz/z4+Pv9gYGD/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9iYmL/QUFB/zMzM/8zMzP/MzMz/zY2NutHR0c3AAAAAAAAAAAAAAAAAAAAAAAAAABT + U1MXPDw8rDQ0NP0zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP47 + Ozu5TU1NIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACNjY0AS0tLNTw8PJU9PT3bOzs7/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/Ojo6/0BAQOM8PDycR0dHPo+PjwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////////////////// + ////////////////////+AAAAAAAAAAAAAAAAAAAH+AAAAAAAAAAAAAAAAAAAAfAAAAAAAAAAAAAAAAA + AAADgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAHA + AAAAAAAAAAAAAAAAAAAD4AAAAAAAAAAAAAAAAAAAB/AAAAAAAAAAAAAAAAAAAA////////////////// + //////////////////////////////////////////////8oAAAAQAAAAIAAAAABACAAAAAAAABAAAAT + CwAAEwsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgICAAENDQyYzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQEJCQil0dHQAAAAAAAAAAAAAAAAAPz8/JTc3N8Iz + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Nzc3yD8/PyoA + AAAAQkJCGzU1NedWVlb/xMTE/+/v7//y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/v + 7+//x8fH/1paWv81NTXsQUFBIDg4OJxKSkr/8PDw//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////09PT/T09P/zc3N6Y4ODjum5ub//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////6Ojo/81NTX0MzMz/7W1tf// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////+9 + vb3/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////++vr7/MzMz/zMzM/+1 + tbX/////////////////////////////////29vb/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a + 2tr/2tra/9ra2v/a2tr/5+fn//////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////k5OT/2tra/9ra2v/a + 2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/g4OD//f39//////////////////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9wcHD/9vb2//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////aGho/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1RUVP/h + 4eH//////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////83 + Nzf/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5aWlv// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////2hoaP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/ZWVl//////////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ////////////////////Nzc3/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9mZmb///////////////////////////////////////////////////////////// + //////////////////////////////////////////////9oaGj/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zg4OP/8/Pz/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////////66urv+tra3/ra2t/62trf+tra3/ra2t/62trf+t + ra3/ra2t/62trf+RkZH/NDQ0/zMzM/8zMzP/Y2Nj//////////////////////////////////////// + ////////////////////////////////////////////////////////////////////wsLC/62trf+t + ra3/ra2t/62trf+tra3/ra2t/62trf+tra3/ra2t/6Ojo/9AQED/MzMz/zMzM/82Njb/+/v7//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////////////////////// + /////////////////////////////////////////1FRUf8zMzP/MzMz/2NjY/////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////hISE/zMzM/8z + MzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////// + //////////////////////////////////////////////////////////////9VVVX/MzMz/zMzM/9j + Y2P///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////4iIiP8zMzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8zMzP/tbW1//////// + //////////////////////////7+/v+ysrL/cnJy/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr//39/f// + ////VVVV/zMzM/8zMzP/Y2Nj/////////////////+/v7/+Pj4//bGxs/2tra/9ra2v/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/bGxs/46Ojv/t7e3//////////////////////87Ozv96enr/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/2tra//////+IiIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////////+enp7/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM//9/f3//////1VVVf8zMzP/MzMz/2NjY/////////////r6+v9WVlb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/U1NT//j4+P///////////8zMzP82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/8zMzP//////iIiI/zMzM/8zMzP/NjY2//v7+/// + //////////////++vr7/MzMz/zMzM/+1tbX/////////////////////////////////RkZG/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP//f39//////9VVVX/MzMz/zMzM/9jY2P////////////F + xcX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//A + wMD///////////93d3f/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//MzMz//////4iIiP8z + MzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + /////////zc3N/8zMzP/MzMz/zMzM/9GRkb/T09P/09PT/9PT0//T09P//39/f//////VVVV/zMzM/8z + MzP/Y2Nj////////////tra2/zMzM/8zMzP/MzMz/zU1Nf9PT0//UFBQ/1BQUP9QUFD/UFBQ/09PT/82 + Njb/MzMz/zMzM/8zMzP/sbGx////////////aGho/zMzM/8zMzP/MzMz/z8/P/9PT0//T09P/09PT/9P + T0//09PT//////+IiIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////83Nzf/MzMz/zMzM/9lZWX//f39//////////////////////// + /////////1VVVf8zMzP/MzMz/2NjY////////////7a2tv8zMzP/MzMz/zMzM/+wsLD///////////// + ////////////////////tLS0/zMzM/8zMzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/z8/P//x + 8fH/////////////////////////////////iIiI/zMzM/8zMzP/NjY2//v7+/////////////////++ + vr7/MzMz/zMzM/+1tbX/////////////////////////////////Nzc3/zMzM/8zMzP/goKC//////// + //////////////////////////////9VVVX/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8z + MzP/z8/P/////////////////////////////////9LS0v8zMzP/MzMz/zMzM/+xsbH///////////9o + aGj/MzMz/zMzM/9QUFD//////////////////////////////////////4iIiP8zMzP/MzMz/zY2Nv/7 + +/v/////////////////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8z + MzP/MzMz/4KCgv//////////////////////////////////////VVVV/zMzM/8zMzP/Y2Nj//////// + ////tra2/zMzM/8zMzP/MzMz/8/Pz//////////////////////////////////S0tL/MzMz/zMzM/8z + MzP/sbGx////////////aGho/zMzM/8zMzP/UFBQ//////////////////////////////////////+I + iIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////83Nzf/MzMz/zMzM/+CgoL//////////////////////////////////////1VVVf8z + MzP/MzMz/2NjY////////////7a2tv8zMzP/MzMz/zMzM//Pz8////////////////////////////// + ////0tLS/zMzM/8zMzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/1BQUP////////////////// + ////////////////////iIiI/zMzM/8zMzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1 + tbX/////////////////////////////////Nzc3/zMzM/8zMzP/goKC//////////////////////// + //////////////9VVVX/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8zMzP/z8/P//////// + /////////////////////////9LS0v8zMzP/MzMz/zMzM/+xsbH///////////9oaGj/MzMz/zMzM/9Q + UFD//////////////////////////////////////4iIiP8zMzP/MzMz/zY2Nv/7+/v///////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8zMzP/MzMz/4KCgv// + ////////////////////////////////////VVVV/zMzM/8zMzP/Y2Nj////////////tra2/zMzM/8z + MzP/MzMz/8/Pz//////////////////////////////////S0tL/MzMz/zMzM/8zMzP/sbGx//////// + ////aGho/zMzM/8zMzP/UFBQ//////////////////////////////////////+IiIj/MzMz/zMzM/82 + Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////83 + Nzf/MzMz/zMzM/93d3f//////////////////////////////////v7+/0tLS/8zMzP/MzMz/2NjY/// + /////////7a2tv8zMzP/MzMz/zMzM//ExMT/////////////////////////////////yMjI/zMzM/8z + MzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/0dHR//9/f3///////////////////////////// + ////fX19/zMzM/8zMzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ////////////////////Nzc3/zMzM/8zMzP/NjY2/3h4eP+EhIT/hISE/4SEhP+EhIT/hISE/2lpaf8z + MzP/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8zMzP/SkpK/4KCgv+EhIT/hISE/4SEhP+E + hIT/g4OD/0xMTP8zMzP/MzMz/zMzM/+xsbH///////////9oaGj/MzMz/zMzM/8zMzP/Z2dn/4SEhP+E + hIT/hISE/4SEhP+EhIT/enp6/zY2Nv8zMzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////////z09Pf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ampq////////////vLy8/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/t7e3////////////b29v/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr//f39//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////99fX3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/6qqqv///////////+/v7/9A + QED/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Pj4+/+zs7P// + /////////6+vr/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/eHh4//////////////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////// + ////9PT0/319ff89PT3/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/RERE/5iYmP/+ + /v7/////////////////0NDQ/1paWv84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84 + ODj/WVlZ/83Nzf/////////////////+/v7/nJyc/0VFRf84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84 + ODj/ODg4/zg4OP89PT3/e3t7//Ly8v//////////////////////vr6+/zMzM/8zMzP/tbW1//////// + //////////////////////////////////////////7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+/////////////////////////////////////////////v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+/////////////////////////////////////////////v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//////////////////////////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////++ + vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////Hx8f+9 + vb3/5+fn/////////////////////////////////9LS0v/FxcX//Pz8///////////////////////j + 4+P/wcHB/7q6uv/Ozs7/+Pj4//////////////////////////////////n5+f/r6+v/9/f3//////// + ////9/f3/7+/v//n5+f///////////////////////////////////////z8/P/T09P/u7u7/8DAwP/l + 5eX//////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////9dXV3/MzMz/0FBQf/z8/P//////////////////////6urq/8zMzP/MzMz/5SUlP// + /////////9zc3P9iYmL/MzMz/zMzM/8zMzP/MzMz/0BAQP+goKD//v7+/////////////////5SUlP85 + OTn/MzMz/zU1Nf9wcHD//f39/3l5ef8zMzP/Q0ND//b29v///////////////////////////7m5uf9J + SUn/MzMz/zMzM/8zMzP/MzMz/2hoaP/i4uL///////////////////////////++vr7/MzMz/zMzM/+1 + tbX////////////////////////////8/Pz/NTU1/zMzM/8zMzP/1tbW/////////////////+Li4v87 + Ozv/MzMz/zMzM/+YmJj//////+Pj4/9BQUH/MzMz/zMzM/9OTk7/YWFh/zo6Ov8zMzP/MzMz/56env// + /////////+fn5/80NDT/MzMz/0lJSf9gYGD/YmJi//v7+/9UVFT/MzMz/zMzM//g4OD///////////// + /////////7Kysv80NDT/MzMz/zMzM/9BQUH/OTk5/zMzM/8zMzP/RkZG/+np6f////////////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////Pz8/zQ0NP8zMzP/MzMz/9XV1f// + //////////7+/v9oaGj/MzMz/zMzM/8+Pj7/7e3t//////96enr/MzMz/zMzM/+Li4v//v7+///////q + 6ur/aGho/zMzM/9lZWX////////////Ozs7/MzMz/zMzM//Dw8P/////////////////U1NT/zMzM/8z + MzP/39/f//////////////////b29v9BQUH/MzMz/zQ0NP+zs7P//v7+//T09P9ycnL/MzMz/zMzM/+C + goL//////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////z8/P80 + NDT/MzMz/zMzM//V1dX///////////+tra3/MzMz/zMzM/8zMzP/qqqq///////+/v7/QEBA/zMzM/8z + MzP/z8/P/97e3v/e3t7/3t7e/9ra2v+oqKj/5OTk////////////ysrK/zMzM/8zMzP/zMzM//////// + /////////1NTU/8zMzP/MzMz/9/f3//////////////////Dw8P/MzMz/zMzM/9iYmL///////////// + ////5OTk/zMzM/8zMzP/Q0ND//7+/v////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ///////////////8/Pz/NDQ0/zMzM/8zMzP/1dXV///////Jycn/Ozs7/zMzM/8zMzP/e3t7//7+/v// + ////9fX1/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/4CAgP///////////8rKyv8z + MzP/MzMz/8zMzP////////////////9TU1P/MzMz/zMzM//Z2dn/////////////////ra2t/zMzM/8z + MzP/g4OD//////////////////////84ODj/MzMz/zQ0NP/39/f/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////Pz8/zQ0NP8zMzP/MzMz/1VVVf9TU1P/NDQ0/zMzM/8z + MzP/WVlZ//b29v////////////v7+/84ODj/MzMz/zMzM/9ra2v/b29v/29vb/9ubm7/MzMz/zMzM/9A + QED////////////Kysr/MzMz/zMzM//MzMz/////////////////U1NT/zMzM/8zMzP/w8PD//////// + /////////7i4uP8zMzP/MzMz/3Nzc//////////////////09PT/NDQ0/zMzM/86Ojr//Pz8//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////z8/P80NDT/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9KSkr/zc3N////////////ZGRk/zMzM/8zMzP/vb29//////// + ////ysrK/zMzM/8zMzP/XV1d////////////ysrK/zMzM/8zMzP/zMzM/////////////////1NTU/8z + MzP/MzMz/4ODg////////Pz8/+/v7//n5+f/NTU1/zMzM/88PDz/5OTk////////////oqKi/zMzM/8z + MzP/aGho//////////////////////++vr7/MzMz/zMzM/+1tbX////////////////////////////8 + /Pz/NDQ0/zMzM/8zMzP/f39//5KSkv+SkpL/g4OD/01NTf8zMzP/MzMz/z8/P//v7+///////8PDw/80 + NDT/MzMz/zw8PP+IiIj/i4uL/0BAQP8zMzP/MzMz/7W1tf//////2tra/4GBgf8zMzP/MzMz/4KCgv+u + rq7/+vr6//////9TU1P/MzMz/zMzM/8zMzP/UlJS/0BAQP81NTX/v7+//4GBgf8zMzP/MzMz/0NDQ/+B + gYH/cHBw/zQ0NP8zMzP/NTU1/8vLy///////////////////////vr6+/zMzM/8zMzP/tbW1//////// + /////////////////////Pz8/zQ0NP8zMzP/MzMz/9XV1f/////////////////29vb/Q0ND/zMzM/8z + MzP/tra2////////////paWl/zg4OP8zMzP/MzMz/zMzM/8zMzP/NTU1/5eXl////////////4CAgP8z + MzP/MzMz/zMzM/8zMzP/NDQ0/9ra2v//////ZGRk/zMzM/85OTn/Pj4+/zMzM/8zMzP/MzMz/66urv/3 + 9/f/dXV1/zMzM/8zMzP/MzMz/zMzM/8zMzP/PDw8/7Ozs////////////////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////z8/P80NDT/MzMz/zMzM//V1dX///////////// + ////+vr6/0VFRf8zMzP/MzMz/62trf/////////////////l5eX/n5+f/35+fv97e3v/mJiY/9zc3P// + ///////////////5+fn/r6+v/zMzM/8zMzP/sLCw/+Tk5P///////////9bW1v99fX3/tra2/9jY2P+D + g4P/gYGB/7y8vP/+/v7////////////Q0ND/k5OT/3p6ev+AgID/paWl/+vr6/////////////////// + //////////////++vr7/MzMz/zMzM/+1tbX////////////////////////////8/Pz/NDQ0/zMzM/8z + MzP/i4uL/6Ghof+hoaH/mJiY/2BgYP8zMzP/MzMz/zMzM//Z2dn///////////////////////////// + /////////////////////////////////////////8rKyv8zMzP/MzMz/83Nzf////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + /////v7+/zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+FhYX///////////// + ///////////////////////////////////////////////////////////////W1tb/MzMz/zMzM//Z + 2dn///////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////+goKD/R0dH/0BAQP9AQED/QEBA/0BAQP9AQED/SEhI/2VlZf+x + sbH//v7+//////////////////////////////////////////////////////////////////////// + /////v7+/62trf+wsLD///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////++ + vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////++vr7/MzMz/zMzM/+1 + tbX///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////+9vb3/MzMz/zc3N/alpaX///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////ra2t/zQ0NPo3 + NzeyWlpa//v7+/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////f39/2FhYf83Nze8Pj4+MTQ0NPh1dXX/6+vr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////7e3t/3t7e/80NDT6Pj4+OQAAAAA8PDxJNTU16jY2Nv9MTEz/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/TU1N/zY2Nv81NTXtOzs7UAAAAAAAAAAAAAAAAEtLSw09 + PT1cNzc3gDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDc3N4A+Pj5gSEhIEAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAP//////////wAAAAAAAAAOAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAHAAAAAAAAAA///////////KAAAADAAAABg + AAAAAQAgAAAAAAAAJAAAEwsAABMLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9PT0lNzc3kTMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzY2NpQ8PDwoAAAAADs7OzM6 + OjrvkpKS/8DAwP/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/C + wsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/C + wsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wcHB/5WVlf87 + OzvxOzs7ODY2NsGtra3///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////+0tLT/NjY2yTs7O/zv7+////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////z8/P/Ozs7/jo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////01NTf9KSkr/SkpK/0pKSv9KSkr/SkpK/0pKSv9K + Skr/SkpK/11dXf/MzMz///////////////////////////////////////////////////////////// + ////////////////////m5ub/0pKSv9KSkr/SkpK/0pKSv9KSkr/SkpK/0pKSv9KSkr/Tk5O/5CQkP/8 + /Pz////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9ISEj/+vr6//////////////////////////////////////// + ////////////////////////////////////jo6O/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+2trb////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////15eXv9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9YWFj/NTU1/zMzM/80NDT/7+/v//////////////////////// + ////////////////////////////////////////////////////pKSk/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9DQ0P/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + ////////////////////////////////////////////////////dHR0/zMzM/80NDT/7+/v//////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////MzMz/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT////////////////////////////6+vr/8/Pz//Pz8//z8/P/8/Pz//b29v//////gICA/zMzM/80 + NDT/7+/v/////////////Pz8//T09P/z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//29vb///////////// + //////////7+/v/09PT/8/Pz//Pz8//z8/P/8/Pz//39/f/Z2dn/MzMz/zMzM/+YmJj////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////8HBwf9HR0f/NjY2/zY2Nv82Njb/NjY2/2dnZ/// + ////gICA/zMzM/80NDT/7+/v///////e3t7/VVVV/zY2Nv82Njb/NjY2/zY2Nv82Njb/NjY2/zY2Nv88 + PDz/kJCQ////////////8/Pz/2pqav84ODj/NjY2/zY2Nv82Njb/NjY2/9nZ2f/Z2dn/MzMz/zMzM/+Y + mJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////0pKSv8zMzP/MzMz/zMzM/8z + MzP/MzMz/2VlZf//////gICA/zMzM/80NDT/7+/v//////92dnb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/9jY2P//////oqKi/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9nZ2f/Z + 2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8z + MzP/NjY2/3V1df97e3v/e3t7/5ubm///////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/zMzM/9o + aGj/fHx8/3x8fP98fHz/e3t7/0ZGRv8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/WVlZ/3t7e/97 + e3v/e3t7/+bm5v/Z2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////////zY2Nv8zMzP/ampq////////////////////////////gICA/zMzM/80NDT/7+/v//////9i + YmL/MzMz/0FBQf/7+/v//////////////////////6ampv8zMzP/MzMz/8TExP//////jo6O/zMzM/8z + MzP/3d3d///////////////////////Z2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////////zY2Nv8zMzP/bm5u////////////////////////////gICA/zMzM/80 + NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7//////////////////////6urq/8zMzP/MzMz/8TExP// + ////jo6O/zMzM/8zMzP/4uLi///////////////////////Z2dn/MzMz/zMzM/+YmJj////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/bm5u//////////////////////// + ////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7//////////////////////6urq/8z + MzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/4uLi///////////////////////Z2dn/MzMz/zMzM/+Y + mJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/bm5u//////// + ////////////////////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7///////////// + /////////6urq/8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/4uLi///////////////////////Z + 2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8z + MzP/aGho////////////////////////////enp6/zMzM/80NDT/7+/v//////9iYmL/MzMz/0BAQP/7 + +/v//////////////////////6Wlpf8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/29vb//////// + ///////////////S0tL/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////////zY2Nv8zMzP/NDQ0/2lpaf9vb2//b29v/29vb/9ra2v/Nzc3/zMzM/80NDT/7+/v//////9i + YmL/MzMz/zMzM/9dXV3/b29v/29vb/9vb2//b29v/0FBQf8zMzP/MzMz/8TExP//////jo6O/zMzM/8z + MzP/UFBQ/29vb/9vb2//b29v/29vb/9OTk7/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////////01NTf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9D + Q0P/+Pj4//////95eXn/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0/9vb2/// + ////paWl/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+wsLD////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////8nJyf9NTU3/Nzc3/zc3N/83Nzf/Nzc3/zc3N/83 + Nzf/Nzc3/0hISP+9vb3////////////j4+P/Xl5e/zc3N/83Nzf/Nzc3/zc3N/83Nzf/Nzc3/zc3N/8/ + Pz//m5ub////////////9vb2/3R0dP85OTn/Nzc3/zc3N/83Nzf/Nzc3/zc3N/83Nzf/OTk5/3t7e//4 + +Pj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////////////////v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+//////////////////////////////////7+/v/+/v7//v7+//7+/v/+ + /v7//v7+//7+/v/////////////////////////////////+/v7//v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+///////////////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT///////////////////////7+/v/6+vr////////////////////////////5 + +fn///////////////////////7+/v/39/f//Pz8//////////////////////////////////////// + //////////7+/v/6+vr//////////////////////////////////v7+//b29v/9/f3///////////// + ///////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////2xsbP9ERET/2dnZ//////// + /////////6ampv8+Pj7/lJSU///////6+vr/mJiY/01NTf88PDz/QUFB/3Z2dv/j4+P////////////g + 4OD/bm5u/1hYWP90dHT/6+vr/39/f/9ERET/3Nzc//////////////////v7+/+bm5v/TU1N/zs7O/9F + RUX/goKC/+/v7//////////////////4+Pj/PDw8/zo6Ov/09PT//////////////////f39/zQ0NP8z + MzP/ra2t////////////2tra/zg4OP8zMzP/ioqK//////9+fn7/MzMz/0ZGRv+Hh4f/ZmZm/zQ0NP9M + TEz/+Pj4//////+Dg4P/MzMz/29vb/+BgYH/5eXl/0tLS/8zMzP/tLS0/////////////////4GBgf8z + MzP/Pj4+/3BwcP9JSUn/MzMz/1lZWf/4+Pj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////Pz8/zQ0NP8zMzP/ra2t///////7+/v/YGBg/zMzM/9AQED/5+fn/+Xl5f82Njb/MzMz/9bW1v// + /////v7+/6mpqf9vb2//9/f3//////9ycnL/MzMz/9fX1////////////0tLS/8zMzP/tLS0//////// + ////5ubm/zQ0NP80NDT/y8vL///////s7Oz/QUFB/zMzM/+5ubn////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////Pz8/zQ0NP8zMzP/ra2t//39/f+Pj4//MzMz/zU1Nf+4uLj//////8fHx/8z + MzP/MzMz/01NTf9OTk7/Tk5O/09PT/9wcHD/9fX1//////9xcXH/MzMz/9nZ2f///////////0tLS/8z + MzP/sLCw////////////w8PD/zMzM/9AQED/+fn5////////////Z2dn/zMzM/+VlZX////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8zMzP/RkZG/0ZGRv8zMzP/MzMz/2pqav/x + 8fH//////9LS0v8zMzP/MzMz/4uLi/+Tk5P/jo6O/zMzM/8zMzP/2NjY//////9xcXH/MzMz/9nZ2f// + /////////0tLS/8zMzP/mZmZ////////////z8/P/zMzM/84ODj/7u7u///////+/v7/V1dX/zMzM/+h + oaH////////////4+Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8zMzP/QEBA/0lJSf9G + Rkb/NjY2/zMzM/9NTU3/6enp//j4+P9LS0v/MzMz/4yMjP/f39//lZWV/zMzM/9FRUX/9vb2//b29v9q + amr/MzMz/8bGxv/5+fn//////0tLS/8zMzP/S0tL/6+vr/+SkpL/z8/P/0lJSf8zMzP/enp6/9PT0/+b + m5v/NDQ0/zg4OP/e3t7////////////4+Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8z + MzP/rKys//39/f/8/Pz/4ODg/zw8PP8zMzP/nJyc///////Ly8v/QUFB/zMzM/8zMzP/MzMz/z09Pf/C + wsL//////3R0dP8zMzP/MzMz/zQ0NP+Ghob//////1VVVf8zMzP/PDw8/zMzM/8zMzP/jIyM/83Nzf9D + Q0P/MzMz/zMzM/8zMzP/ODg4/62trf/////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////Pz8/zQ0NP8zMzP/ra2t////////////6+vr/z09Pf8zMzP/kpKS////////////8PDw/7Kysv+b + m5v/ra2t/+zs7P////////////X19f9oaGj/MzMz/8LCwv/39/f//////9HR0f+oqKj/5eXl/6CgoP+z + s7P/+Pj4///////y8vL/tLS0/5ubm/+rq6v/5ubm///////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////f39/zU1Nf8zMzP/RkZG/1NTU/9RUVH/PT09/zMzM/83Nzf/0tLS//////// + //////////////////////////////////////////////90dHT/MzMz/9vb2/////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////3l5ef8+Pj7/PT09/z09Pf89PT3/QkJC/2FhYf/B + wcH////////////////////////////////////////////////////////////FxcX/i4uL//r6+v// + //////////////////////////////////////////////////////////////////////////////// + ///////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zs7O/7x8fH///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////19fX/Ozs7/zY2NtC/v7////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////FxcX/NjY21zo6OkpDQ0P5srKy/+Dg4P/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4ODg/7W1tf9FRUX7OTk5UHFxcQA6OjpCNjY2uTQ0NN8z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfNDQ03zc3N7w6OjpGa2trAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAP///////wAAgAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////8AACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAABMLAAAT + CwAAAAAAAAAAAAAAAAAARERECjMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAz + MzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAz + MzMgMzMzIDMzMyBDQ0MKAAAAADc3N0pjY2PwkpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+S + kpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+S + kpL/kpKS/5KSkv+SkpL/kpKS/2VlZfE3NzdOWVlZ4vv7+/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////Pz8/1tbW+Z0dHT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////eHh4/3R0dP////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////94eHj/dHR0//////// + /////////4iIiP+Hh4f/h4eH/4eHh/+Hh4f/h4eH/6Kiov/9/f3///////////////////////////// + ////////////////////0tLS/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/iIiI/8zMzP///////////3h4eP90 + dHT/////////////////NTU1/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/7+/v/////////////////// + //////////////////////////////+0tLT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/QUFB//7+/v// + ////eHh4/3R0dP/////////////////W1tb/1tbW/9bW1v/W1tb/1tbW/4WFhf8zMzP/sbGx//////// + //////////////////////////////////////////Dw8P/W1tb/1tbW/9bW1v/W1tb/09PT/0pKSv81 + NTX//f39//////94eHj/dHR0/////////////////+zs7P+3t7f/tbW1/7W1tf/a2tr/qqqq/zMzM/+x + sbH//////9/f3/+1tbX/tbW1/7W1tf+1tbX/tbW1/97e3v///////////9HR0f+1tbX/tbW1/7W1tf/2 + 9vb/XV1d/zU1Nf/9/f3//////3h4eP90dHT/////////////////UlJS/zMzM/8zMzP/MzMz/5iYmP+q + qqr/MzMz/7Gxsf/v7+//PDw8/zMzM/8zMzP/MzMz/zMzM/8zMzP/Ozs7/+7u7v/Q0ND/NDQ0/zMzM/8z + MzP/MzMz/+Xl5f9dXV3/NTU1//39/f//////eHh4/3R0dP////////////////81NTX/QEBA/6SkpP+n + p6f/09PT/6qqqv8zMzP/sbGx/9ra2v8zMzP/U1NT/6enp/+np6f/p6en/1RUVP8zMzP/2NjY/7S0tP8z + MzP/aGho/6enp/+np6f/9PT0/11dXf81NTX//f39//////94eHj/dHR0/////////////////zU1Nf9b + W1v/////////////////qqqq/zMzM/+xsbH/2tra/zMzM/+BgYH/////////////////g4OD/zMzM//Y + 2Nj/tLS0/zMzM/+np6f/////////////////XV1d/zU1Nf/9/f3//////3h4eP90dHT///////////// + ////NTU1/1tbW/////////////////+qqqr/MzMz/7Gxsf/a2tr/MzMz/4GBgf////////////////+D + g4P/MzMz/9jY2P+0tLT/MzMz/6enp/////////////////9dXV3/NTU1//39/f//////eHh4/3R0dP// + //////////////81NTX/WFhY/////////////////6enp/8zMzP/sbGx/9ra2v8zMzP/fn5+//////// + /////////4CAgP8zMzP/2NjY/7S0tP8zMzP/paWl/////////////////1tbW/81NTX//f39//////94 + eHj/dHR0/////////////////zY2Nv80NDT/WVlZ/1tbW/9bW1v/QUFB/zMzM/+zs7P/3Nzc/zMzM/85 + OTn/W1tb/1tbW/9bW1v/OTk5/zMzM//Z2dn/tbW1/zMzM/9AQED/W1tb/1tbW/9ZWVn/NDQ0/zY2Nv/9 + /f3//////3h4eP90dHT/////////////////iIiI/zc3N/81NTX/NTU1/zU1Nf81NTX/UVFR/+np6f/7 + +/v/Z2dn/zY2Nv81NTX/NTU1/zU1Nf82Njb/ZmZm//r6+v/r6+v/UlJS/zU1Nf81NTX/NTU1/zU1Nf83 + Nzf/hoaG////////////eHh4/3R0dP////////////////////////////7+/v/+/v7//v7+//7+/v// + /////////////////////v7+//7+/v/+/v7//v7+//7+/v///////////////////////v7+//7+/v/+ + /v7//v7+//////////////////////94eHj/dHR0//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3h4eP90dHT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////eHh4/3R0dP////////////////+Q + kJD/xsbG////////////rKys/6Kiov//////z8/P/4KCgv97e3v/tra2////////////sbGx/5OTk//b + 29v/mZmZ/8jIyP///////////+3t7f+Tk5P/eHh4/6CgoP/4+Pj///////////94eHj/dHR0//////// + /////f39/zQ0NP+EhIT//////9LS0v81NTX/fX19/9fX1/83Nzf/g4OD/6Ghof9AQED/wMDA/+3t7f8z + MzP/m5ub/9fX1/9DQ0P/iYmJ///////9/f3/VlZW/1NTU/+bm5v/Q0ND/3l5ef///////////3h4eP90 + dHT////////////9/f3/NDQ0/4SEhP/y8vL/U1NT/0VFRf/q6ur/mZmZ/zMzM/+FhYX/iYmJ/3p6ev/Y + 2Nj/5OTk/zMzM//m5ub//////0NDQ/+Hh4f//////9zc3P8zMzP/ubm5//////+UlJT/Nzc3//39/f// + ////eHh4/3R0dP////////////39/f80NDT/PDw8/zs7O/8zMzP/c3Nz//Pz8/+lpaX/MzMz/6ampv+q + qqr/MzMz/6enp//k5OT/MzMz/+bm5v//////Q0ND/2tra//+/v7/4+Pj/zMzM/+kpKT//////39/f/9C + QkL//v7+//////94eHj/dHR0/////////////f39/zQ0NP9vb2//ycnJ/7Gxsf83Nzf/hoaG//Dw8P9R + UVH/S0tL/0xMTP9NTU3/7Ozs/4SEhP8zMzP/ZmZm//X19f9HR0f/Nzc3/z4+Pv91dXX/iIiI/zc3N/9W + Vlb/NTU1/6ysrP///////////3h4eP90dHT////////////9/f3/NDQ0/3Fxcf/Q0ND/vLy8/zg4OP97 + e3v///////j4+P/Hx8f/xMTE//b29v//////3Nzc/zMzM//Y2Nj//////9TU1P/j4+P/wcHB/+7u7v// + ////2NjY/76+vv/k5OT/////////////////eHh4/3R0dP////////////////9VVVX/OTk5/zk5Of88 + PDz/X19f/+Dg4P/////////////////////////////////19fX/cXFx//X19f////////////////// + //////////////////////////////////////////////94eHj/dHR0//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3h4eP90dHT///////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////eHh4/3R0dP// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////94 + eHj/X19f6v7+/v////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////v7+/2FhYe03NzdcdHR0+qampv+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+n + p6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+n + p6f/p6en/6enp/92dnb7Nzc3YQAAAAA+Pj4aNTU1QDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNANTU1QEBAQBwAAAAAgAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAEoAAAAEAAAACAAAAABACAAAAAAAAAEAAAT + CwAAEwsAAAAAAAAAAAAAWFhYUYeHh4+IiIiPiIiIj4iIiI+IiIiPiIiIj4iIiI+IiIiPiIiIj4iIiI+I + iIiPiIiIj4iIiI+IiIiPWVlZUrS0tPj///////////////////////////////////////////////// + /////////////////////////7a2tvm6urr//////8PDw//Dw8P/w8PD/+fn5/////////////////// + ////9PT0/8PDw//Dw8P/w8PD//Ly8v+8vLz/urq6//////+FhYX/hISE/3BwcP91dXX///////////// + /////////+jo6P+EhIT/hISE/2FhYf+cnJz/vLy8/7q6uv//////ioqK/3R0dP+ysrL/cnJy/8LCwv90 + dHT/dHR0/4CAgP/v7+//e3t7/3R0dP+lpaX/mZmZ/7y8vP+6urr//////0FBQf/S0tL/ycnJ/3Jycv+H + h4f/n5+f/9PT0/9PT0//xsbG/11dXf/T09P/q6ur/5mZmf+8vLz/urq6//////9HR0f//////9TU1P9y + cnL/h4eH/7+/v///////Wlpa/8bGxv9tbW3//////62trf+ZmZn/vLy8/7q6uv//////SkpK/0hISP9C + QkL/iIiI/5ycnP9AQED/SEhI/0JCQv/d3d3/Pz8//0hISP8+Pj7/rq6u/7y8vP+6urr///////////// + //////////////////////////////////////////////////////////////+8vLz/urq6///////V + 1dX//////9PT0//z8/P/v7+//+3t7f/s7Oz/29vb/9jY2P//////4ODg/8XFxf/9/f3/vLy8/7q6uv/+ + /v7/XFxc/8XFxf94eHj/dnZ2/4yMjP+VlZX/jo6O/9bW1v9mZmb/9vb2/2VlZf+cnJz/q6ur/7y8vP+6 + urr//v7+/0RERP96enr/iYmJ/4aGhv96enr/hYWF/3R0dP/Q0ND/S0tL/6Wlpf9mZmb/goKC/7u7u/+8 + vLz/urq6//7+/v9NTU3/gICA/3x8fP/9/f3/4uLi//39/f+dnZ3/8/Pz/+3t7f/r6+v/9fX1/+jo6P// + ////vLy8/7q6uv////////////////////////////////////////////////////////////////// + /////////7y8vP+2trb6//////////////////////////////////////////////////////////// + //////////////+3t7f7YWFhXJCQkJ+QkJCfkJCQn5CQkJ+QkJCfkJCQn5CQkJ+QkJCfkJCQn5CQkJ+Q + kJCfkJCQn5CQkJ+QkJCfYmJiXgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + + + \ No newline at end of file diff --git a/src/RetroGOG/frmCoreSelect.Designer.cs b/src/RetroGOG/frmCoreSelect.Designer.cs new file mode 100644 index 0000000..747de14 --- /dev/null +++ b/src/RetroGOG/frmCoreSelect.Designer.cs @@ -0,0 +1,104 @@ +namespace RetroGOG +{ + partial class frmCoreSelect + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.cboCore = new System.Windows.Forms.ComboBox(); + this.btnSave = new System.Windows.Forms.Button(); + this.lblConsoleName = new System.Windows.Forms.Label(); + this.label1 = new System.Windows.Forms.Label(); + this.SuspendLayout(); + // + // cboCore + // + this.cboCore.FormattingEnabled = true; + this.cboCore.Location = new System.Drawing.Point(12, 47); + this.cboCore.Name = "cboCore"; + this.cboCore.Size = new System.Drawing.Size(369, 21); + this.cboCore.TabIndex = 0; + // + // btnSave + // + this.btnSave.Location = new System.Drawing.Point(306, 74); + this.btnSave.Name = "btnSave"; + this.btnSave.Size = new System.Drawing.Size(75, 23); + this.btnSave.TabIndex = 1; + this.btnSave.Text = "&Save"; + this.btnSave.UseVisualStyleBackColor = true; + this.btnSave.Click += new System.EventHandler(this.btnSave_Click); + // + // lblConsoleName + // + this.lblConsoleName.AutoSize = true; + this.lblConsoleName.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblConsoleName.Location = new System.Drawing.Point(9, 26); + this.lblConsoleName.Name = "lblConsoleName"; + this.lblConsoleName.Size = new System.Drawing.Size(46, 18); + this.lblConsoleName.TabIndex = 2; + this.lblConsoleName.Text = "label1"; + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.label1.Location = new System.Drawing.Point(9, 8); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(129, 18); + this.label1.TabIndex = 3; + this.label1.Text = "Choose a core for"; + // + // frmCoreSelect + // + this.AcceptButton = this.btnSave; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(393, 108); + this.Controls.Add(this.label1); + this.Controls.Add(this.lblConsoleName); + this.Controls.Add(this.btnSave); + this.Controls.Add(this.cboCore); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + this.MaximizeBox = false; + this.Name = "frmCoreSelect"; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Select Retroarch Plugin"; + this.Load += new System.EventHandler(this.frmCoreSelect_Load); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.ComboBox cboCore; + private System.Windows.Forms.Button btnSave; + private System.Windows.Forms.Label lblConsoleName; + private System.Windows.Forms.Label label1; + } +} \ No newline at end of file diff --git a/src/RetroGOG/frmCoreSelect.cs b/src/RetroGOG/frmCoreSelect.cs new file mode 100644 index 0000000..99d80f0 --- /dev/null +++ b/src/RetroGOG/frmCoreSelect.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace RetroGOG +{ + public partial class frmCoreSelect : Form + { + public frmCoreSelect() + { + InitializeComponent(); + } + + private void frmCoreSelect_Load(object sender, EventArgs e) + { + lblConsoleName.Text = this.Text; + this.Text = "Select Retorarch Plugin"; + var files = Directory.EnumerateFiles(Globals.RAPath.Replace("retroarch.exe", "cores\\")).Select(Path.GetFileName); + foreach (var file in files) + { + cboCore.Items.Add(file); + } + } + + private void btnSave_Click(object sender, EventArgs e) + { + Globals.TempCore = cboCore.Text; + this.Close(); + } + } +} diff --git a/src/RetroGOG/frmCoreSelect.resx b/src/RetroGOG/frmCoreSelect.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/src/RetroGOG/frmCoreSelect.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/src/RetroGOG/frmDependencies.Designer.cs b/src/RetroGOG/frmDependencies.Designer.cs new file mode 100644 index 0000000..d97844d --- /dev/null +++ b/src/RetroGOG/frmDependencies.Designer.cs @@ -0,0 +1,268 @@ +namespace RetroGOG +{ + partial class frmDependencies + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.components = new System.ComponentModel.Container(); + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(frmDependencies)); + this.btnAbout = new System.Windows.Forms.Button(); + this.btnCancel = new System.Windows.Forms.Button(); + this.btnNext = new System.Windows.Forms.Button(); + this.btnBack = new System.Windows.Forms.Button(); + this.pictureBox2 = new System.Windows.Forms.PictureBox(); + this.pictureBox1 = new System.Windows.Forms.PictureBox(); + this.lblExplain = new System.Windows.Forms.Label(); + this.imgRetroStatus = new System.Windows.Forms.PictureBox(); + this.imgGOGStatus = new System.Windows.Forms.PictureBox(); + this.lblRetroStatus = new System.Windows.Forms.Label(); + this.lblGOGStatus = new System.Windows.Forms.Label(); + this.lblRetroBrowse = new System.Windows.Forms.Label(); + this.txtRetroPath = new System.Windows.Forms.TextBox(); + this.btnRetroBrowse = new System.Windows.Forms.Button(); + this.tmrCheck = new System.Windows.Forms.Timer(this.components); + this.btnDownloadGOG = new System.Windows.Forms.Button(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox2)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.imgRetroStatus)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.imgGOGStatus)).BeginInit(); + this.SuspendLayout(); + // + // btnAbout + // + this.btnAbout.Location = new System.Drawing.Point(12, 343); + this.btnAbout.Name = "btnAbout"; + this.btnAbout.Size = new System.Drawing.Size(75, 23); + this.btnAbout.TabIndex = 8; + this.btnAbout.Text = "&About"; + this.btnAbout.UseVisualStyleBackColor = true; + this.btnAbout.Click += new System.EventHandler(this.btnAbout_Click); + // + // btnCancel + // + this.btnCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.btnCancel.Location = new System.Drawing.Point(543, 343); + this.btnCancel.Name = "btnCancel"; + this.btnCancel.Size = new System.Drawing.Size(75, 23); + this.btnCancel.TabIndex = 7; + this.btnCancel.Text = "&Cancel"; + this.btnCancel.UseVisualStyleBackColor = true; + this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click); + // + // btnNext + // + this.btnNext.Enabled = false; + this.btnNext.Location = new System.Drawing.Point(462, 343); + this.btnNext.Name = "btnNext"; + this.btnNext.Size = new System.Drawing.Size(75, 23); + this.btnNext.TabIndex = 6; + this.btnNext.Text = "&Next > >"; + this.btnNext.UseVisualStyleBackColor = true; + this.btnNext.Click += new System.EventHandler(this.btnNext_Click); + // + // btnBack + // + this.btnBack.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.btnBack.Location = new System.Drawing.Point(93, 343); + this.btnBack.Name = "btnBack"; + this.btnBack.Size = new System.Drawing.Size(75, 23); + this.btnBack.TabIndex = 9; + this.btnBack.Text = "< < &Back"; + this.btnBack.UseVisualStyleBackColor = true; + this.btnBack.Click += new System.EventHandler(this.btnBack_Click); + // + // pictureBox2 + // + this.pictureBox2.Image = global::RetroGOG.Properties.Resources.galaxy_logo; + this.pictureBox2.Location = new System.Drawing.Point(39, 39); + this.pictureBox2.Name = "pictureBox2"; + this.pictureBox2.Size = new System.Drawing.Size(48, 48); + this.pictureBox2.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.pictureBox2.TabIndex = 12; + this.pictureBox2.TabStop = false; + // + // pictureBox1 + // + this.pictureBox1.Image = global::RetroGOG.Properties.Resources.retroarch; + this.pictureBox1.InitialImage = global::RetroGOG.Properties.Resources.retroarch; + this.pictureBox1.Location = new System.Drawing.Point(39, 93); + this.pictureBox1.Name = "pictureBox1"; + this.pictureBox1.Size = new System.Drawing.Size(48, 48); + this.pictureBox1.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.pictureBox1.TabIndex = 11; + this.pictureBox1.TabStop = false; + // + // lblExplain + // + this.lblExplain.AutoSize = true; + this.lblExplain.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblExplain.Location = new System.Drawing.Point(9, 9); + this.lblExplain.Name = "lblExplain"; + this.lblExplain.Size = new System.Drawing.Size(500, 18); + this.lblExplain.TabIndex = 13; + this.lblExplain.Text = "Please wait while we look for your Retroarch and GOG Galaxy applications"; + // + // imgRetroStatus + // + this.imgRetroStatus.Location = new System.Drawing.Point(93, 100); + this.imgRetroStatus.Name = "imgRetroStatus"; + this.imgRetroStatus.Size = new System.Drawing.Size(32, 32); + this.imgRetroStatus.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.imgRetroStatus.TabIndex = 14; + this.imgRetroStatus.TabStop = false; + // + // imgGOGStatus + // + this.imgGOGStatus.Location = new System.Drawing.Point(93, 46); + this.imgGOGStatus.Name = "imgGOGStatus"; + this.imgGOGStatus.Size = new System.Drawing.Size(32, 32); + this.imgGOGStatus.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.imgGOGStatus.TabIndex = 15; + this.imgGOGStatus.TabStop = false; + // + // lblRetroStatus + // + this.lblRetroStatus.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblRetroStatus.Location = new System.Drawing.Point(131, 109); + this.lblRetroStatus.Name = "lblRetroStatus"; + this.lblRetroStatus.Size = new System.Drawing.Size(487, 23); + this.lblRetroStatus.TabIndex = 16; + // + // lblGOGStatus + // + this.lblGOGStatus.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblGOGStatus.Location = new System.Drawing.Point(131, 55); + this.lblGOGStatus.Name = "lblGOGStatus"; + this.lblGOGStatus.Size = new System.Drawing.Size(487, 23); + this.lblGOGStatus.TabIndex = 17; + // + // lblRetroBrowse + // + this.lblRetroBrowse.AutoSize = true; + this.lblRetroBrowse.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblRetroBrowse.Location = new System.Drawing.Point(90, 182); + this.lblRetroBrowse.Name = "lblRetroBrowse"; + this.lblRetroBrowse.Size = new System.Drawing.Size(343, 18); + this.lblRetroBrowse.TabIndex = 18; + this.lblRetroBrowse.Text = "Please enter the path to your Retroarch installation:"; + this.lblRetroBrowse.Visible = false; + // + // txtRetroPath + // + this.txtRetroPath.Enabled = false; + this.txtRetroPath.Location = new System.Drawing.Point(93, 203); + this.txtRetroPath.Name = "txtRetroPath"; + this.txtRetroPath.Size = new System.Drawing.Size(444, 20); + this.txtRetroPath.TabIndex = 19; + this.txtRetroPath.Visible = false; + // + // btnRetroBrowse + // + this.btnRetroBrowse.Location = new System.Drawing.Point(543, 200); + this.btnRetroBrowse.Name = "btnRetroBrowse"; + this.btnRetroBrowse.Size = new System.Drawing.Size(75, 23); + this.btnRetroBrowse.TabIndex = 20; + this.btnRetroBrowse.Text = "B&rowse"; + this.btnRetroBrowse.UseVisualStyleBackColor = true; + this.btnRetroBrowse.Visible = false; + this.btnRetroBrowse.Click += new System.EventHandler(this.btnRetroBrowse_Click); + // + // tmrCheck + // + this.tmrCheck.Enabled = true; + this.tmrCheck.Tick += new System.EventHandler(this.tmrCheck_Tick); + // + // btnDownloadGOG + // + this.btnDownloadGOG.Cursor = System.Windows.Forms.Cursors.Hand; + this.btnDownloadGOG.Image = global::RetroGOG.Properties.Resources.downloadgog; + this.btnDownloadGOG.Location = new System.Drawing.Point(50, 272); + this.btnDownloadGOG.Name = "btnDownloadGOG"; + this.btnDownloadGOG.Size = new System.Drawing.Size(534, 54); + this.btnDownloadGOG.TabIndex = 21; + this.btnDownloadGOG.UseVisualStyleBackColor = true; + this.btnDownloadGOG.Visible = false; + this.btnDownloadGOG.Click += new System.EventHandler(this.btnDownloadGOG_Click); + // + // frmDependencies + // + this.AcceptButton = this.btnNext; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.CancelButton = this.btnBack; + this.ClientSize = new System.Drawing.Size(630, 378); + this.Controls.Add(this.btnDownloadGOG); + this.Controls.Add(this.btnRetroBrowse); + this.Controls.Add(this.txtRetroPath); + this.Controls.Add(this.lblRetroBrowse); + this.Controls.Add(this.lblGOGStatus); + this.Controls.Add(this.lblRetroStatus); + this.Controls.Add(this.imgGOGStatus); + this.Controls.Add(this.imgRetroStatus); + this.Controls.Add(this.lblExplain); + this.Controls.Add(this.pictureBox2); + this.Controls.Add(this.pictureBox1); + this.Controls.Add(this.btnBack); + this.Controls.Add(this.btnAbout); + this.Controls.Add(this.btnCancel); + this.Controls.Add(this.btnNext); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon"))); + this.MaximizeBox = false; + this.Name = "frmDependencies"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "RetroGOG"; + this.Load += new System.EventHandler(this.frmDependencies_Load); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox2)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.imgRetroStatus)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.imgGOGStatus)).EndInit(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Button btnAbout; + private System.Windows.Forms.Button btnCancel; + private System.Windows.Forms.Button btnNext; + private System.Windows.Forms.Button btnBack; + private System.Windows.Forms.PictureBox pictureBox2; + private System.Windows.Forms.PictureBox pictureBox1; + private System.Windows.Forms.Label lblExplain; + private System.Windows.Forms.PictureBox imgRetroStatus; + private System.Windows.Forms.PictureBox imgGOGStatus; + private System.Windows.Forms.Label lblRetroStatus; + private System.Windows.Forms.Label lblGOGStatus; + private System.Windows.Forms.Label lblRetroBrowse; + private System.Windows.Forms.TextBox txtRetroPath; + private System.Windows.Forms.Button btnRetroBrowse; + private System.Windows.Forms.Timer tmrCheck; + private System.Windows.Forms.Button btnDownloadGOG; + } +} \ No newline at end of file diff --git a/src/RetroGOG/frmDependencies.cs b/src/RetroGOG/frmDependencies.cs new file mode 100644 index 0000000..47015ed --- /dev/null +++ b/src/RetroGOG/frmDependencies.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.IO; + + +namespace RetroGOG +{ + public partial class frmDependencies : Form + { + public bool GOGFound = false; + public bool RAFound = false; + + public frmDependencies() + { + InitializeComponent(); + } + + private void btnAbout_Click(object sender, EventArgs e) + { + Form frmAbout = new frmAbout(); + frmAbout.Show(); + } + + private void btnBack_Click(object sender, EventArgs e) + { + this.Hide(); + Form frmMain = new frmMain(); + frmMain.Closed += (s, args) => this.Close(); + frmMain.Show(); + } + + private void btnCancel_Click(object sender, EventArgs e) + { + if (MessageBox.Show("Are you sure you want to exit the wizard? Any unsaved progress will be lost.", "RetroGOG", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1) == System.Windows.Forms.DialogResult.Yes) + { + this.Close(); + } + } + + private void frmDependencies_Load(object sender, EventArgs e) + { + // Check for various dependencies and files / directories + if (File.Exists(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86) + "\\GOG Galaxy\\GalaxyClient.exe")) + { + imgGOGStatus.Image = Properties.Resources.yes; + lblGOGStatus.Text = "GOG Galaxy 2.0 is installed in the default location."; + GOGFound = true; + } + else + { + imgGOGStatus.Image = Properties.Resources.no; + lblGOGStatus.Text = "GOG Galaxy 2.0 was not found on this system."; + btnDownloadGOG.Visible = true; + } + + if (Directory.Exists(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + "\\GOG.com\\Galaxy\\plugins\\installed")) + { + Globals.GOGPluginPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + "\\GOG.com\\Galaxy\\plugins\\installed"; + } + else + { + imgGOGStatus.Image = Properties.Resources.no; + lblGOGStatus.Text = "GOG Galaxy 2.0 was not found on this system."; + GOGFound = false; + } + + if (File.Exists(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) + "\\RetroArch\\retroarch.exe")) + { + imgRetroStatus.Image = Properties.Resources.yes; + lblRetroStatus.Text = "Retroarch found in the default location."; + Globals.RAPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) + "\\RetroArch\\retroarch.exe"; + RAFound = true; + } + else + { + imgRetroStatus.Image = Properties.Resources.warn; + lblRetroStatus.Text = "Retroarch not found in the default location."; + lblRetroBrowse.Visible = true; + txtRetroPath.Visible = true; + btnRetroBrowse.Visible = true; + } + } + + private void tmrCheck_Tick(object sender, EventArgs e) + { + if (GOGFound == true && RAFound == true) + { + btnNext.Enabled = true; + } + } + + private void btnRetroBrowse_Click(object sender, EventArgs e) + { + OpenFileDialog browser = new OpenFileDialog(); + browser.Title = "Please select your retroarch.exe file"; + browser.InitialDirectory = @"c:\"; + browser.Filter = "Retroarch Application|retroarch.exe"; + browser.FilterIndex = 2; + browser.RestoreDirectory = true; + if (browser.ShowDialog() == DialogResult.OK) + { + txtRetroPath.Text = browser.FileName; + imgRetroStatus.Image = Properties.Resources.yes; + lblRetroStatus.Text = "Retroarch location has been entered by user."; + Globals.RAPath = txtRetroPath.Text; + RAFound = true; + } + } + + private void btnNext_Click(object sender, EventArgs e) + { + this.Hide(); + Form frmPluginSelect = new frmPluginSelect(); + frmPluginSelect.Closed += (s, args) => this.Close(); + frmPluginSelect.Show(); + } + + private void btnDownloadGOG_Click(object sender, EventArgs e) + { + System.Diagnostics.Process.Start("https://www.gog.com/galaxy"); + } + } +} diff --git a/src/RetroGOG/frmDependencies.resx b/src/RetroGOG/frmDependencies.resx new file mode 100644 index 0000000..1678609 --- /dev/null +++ b/src/RetroGOG/frmDependencies.resx @@ -0,0 +1,1895 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + True + + + 17, 17 + + + + + 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 + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAICAgAFPT08sPz8/bjMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4A+Pj5xTU1NMnR0dAIA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAImJiQBHR0dEOTk5xzQ0NP4zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk50UVFRVGOjo4AAAAAAAAAAAAAAAAAAAAAAAAAAABs + bGwEPj4+jzQ0NP4zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/z4+PqFdXV0IAAAAAAAAAAAAAAAAZmZmAT09PZ8zMzP/MzMz/zMzM/9iYmL/r6+v/9jY2P/k + 5OT/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5OTk/9ra2v+zs7P/aWlp/zMzM/8zMzP/MzMz/zw8PLJ6enoEAAAAAAAAAABC + QkJqMzMz/zMzM/88PDz/tbW1//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////wcHB/0FBQf8zMzP/MzMz/z8/P34AAAAAWlpaEjY2Nu4zMzP/NjY2/8XFxf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////0tLS/zo6Ov8zMzP/NTU19lFRUR8/ + Pz9xMzMz/zMzM/+Kior///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////m5ub/zMzM/8zMzP/Pj4+hjo6OsAzMzP/NTU1/+Xl5f////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////w8PD/Ozs7/zMzM/85 + OTnVQUFB+zMzM/9TU1P///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////9jY2P/MzMz/zY2Nv01NTX/MzMz/2lpaf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3p6ev8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ////////////////////////////////////uLi4/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/vb29/+Li4v////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////9zc3P+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7e3t//MzMz/9/f3//////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NTU1/2lpaf/h4eH///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9DQ0P/p6en//7+/v////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0NDQ//c3Nz///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/iYmJ//////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1tbW//+ + /v7///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////52dnf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/wsLC//////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/83Nzf////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9qamr/////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/n5+f//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/z8/P//8/Pz///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//f39////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////9hYWH/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1FRUf80NDT/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5SUlP////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////sLCw/1tbW/9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9aWlr/Pz8//zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////8DAwP81NTX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////z8/P/Z2dn/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////2hoaP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+UlJT///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////Nzc3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////9zc3P8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////94eHj/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/lJSU//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/2 + 9vb///////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////c + 3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + ///////////////////////////////////////////////x8fH/vLy8/6Wlpf+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo//9/f3/////////////////eHh4/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////////////////////////////////r6+v/I + yMj/qKio/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/o6Oj/6enp//FxcX/+Pj4//////////////////////////////////////// + /////////////////////v7+/9XV1f+tra3/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/0dHR/////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85 + OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v////////////////////////////////////////////////////////////////////////////9 + /f3/nJyc/z09Pf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NDQ0//v7+/////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////// + //////////////////////////////++vr7/SkpK/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9G + Rkb/t7e3/////////////////////////////////////////////////9vb2/9eXl7/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+ZmZn///////////// + ////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + /////////////////////////////////////v7+/4SEhP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/80NDT/+/v7/////////////////3h4eP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT/////////////////////////////////s7Oz/zU1Nf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/80NDT/q6ur//////////////////////// + ///////////////a2tr/QEBA/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5mZmf/////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/OTk5//b29v///////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr///////////////////////////////////////////////////////////////////////C + wsL/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zQ0NP/7+/v/////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP// + /////////////////////////+rq6v89PT3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/86Ojr/5OTk/////////////////////////////f39/1tbW/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ//////// + /////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////25ubv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0//v7+/////////////////94 + eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////oKCg/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+Wlpb///////////// + ///////////////Q0ND/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+ZmZn/////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zk5Of/29vb///////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////RERE/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/80NDT/+/v7/////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+U + lJT///////////////////////////92dnb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2xsbP///////////////////////////6enp/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5mZmf// + ///////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP/7+/v///////////// + ////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ/////////////////9zc3P8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/85OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/R0dH/2pqav9ra2v/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/a2tr//z8/P////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/89 + PT3/aGho/21tbf9tbW3/bW1t/21tbf9tbW3/bW1t/21tbf9tbW3/bW1t/21tbf9paWn/Pj4+/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zY2Nv9gYGD/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/+1 + tbX/////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/2pqav/39/f///////////////////////////////////////////////////////////// + /////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/SEhI/+bm5v////////////////////////////////// + ///////////////////////////////q6ur/TU1N/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/82Njb/x8fH//////// + ///////////////////////////////////////////////////////////////c3Nz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//b29v///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/xcXF//////////////////////// + ////////////////////////////////////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5SUlP///////////////////////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+T + k5P///////////////////////////////////////////////////////////////////////////+a + mpr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi////////////////////////////nZ2d/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2FhYf////////////////////////////////////////////////// + /////////////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + /////////////////////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM//R0dH///////////////////////////////////////////////////////////// + //////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////////////////////// + ////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////////////////////// + /////////////////////////////////////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9i + YmL///////////////////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////// + ////////////////////////////////////////////////////////////////////3Nzc/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9HR0f////////////////// + /////////////////////////////////////////////////////////3h4eP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+UlJT///////////////////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/n5+f//////////////////////////////////////////////////////////////////////// + ////pqam/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv///////////////////////////52dnf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3///////////////////////////////////////////// + ///////////////////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////86Ojr/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/0dHR//////////////////////////////////////////////////////// + ////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////// + /////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+fn5////////////////////////////// + //////////////////////////////////////////////+mpqb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/YmJi////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/21tbf// + /////////////////////////////////////////////////////////////////////////9zc3P8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + /////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R0dH///////////// + //////////////////////////////////////////////////////////////94eHj/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5+fn/////////////////////////////////////////////////////////////////// + /////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+d + nZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////////////////////////////////////// + ////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/2 + 9vb///////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ////////////////////////////////////////////////////////////////////Ojo6/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/9HR0f////////////////////////////////////////////////// + /////////////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////// + //////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/n5+f//////////////////////// + ////////////////////////////////////////////////////pqam/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/2JiYv///////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9t + bW3////////////////////////////////////////////////////////////////////////////c + 3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/0dHR//////// + ////////////////////////////////////////////////////////////////////eHh4/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+fn5////////////////////////////////////////////////////////////// + //////////////+mpqb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////////////////////// + ////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/21tbf////////////////////////////////// + /////////////////////////////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85 + OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v//////////////////////////////////////////////////////////////////////zo6Ov8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R0dH///////////////////////////////////////////// + //////////////////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////// + ////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////// + /////////////////////////////////////////////////////////6ampv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/bW1t//////////////////////////////////////////////////////////////////////// + ////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9HR0f// + /////////////////////////////////////////////////////////////////////////3h4eP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9sbGz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/n5+f//////////////////////////////////////////////////////// + ////////////////////pqam/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv////////////////// + /////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3///////////////////////////// + ///////////////////////////////////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/OTk5//b29v///////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/0dHR//////////////////////////////////////// + ////////////////////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP// + /////////////////////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+fn5////////////// + //////////////////////////////////////////////////////////////+mpqb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/YmJi////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/21tbf////////////////////////////////////////////////////////////////// + /////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R + 0dH///////////////////////////////////////////////////////////////////////////94 + eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////bGxs/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////////////////////////////////////// + /////////////////////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////// + //////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////////////////////// + ////////////////////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zk5Of/29vb///////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9DQ0P////////////////////////////////// + /////////////////////////////////////////3Z2dv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+U + lJT///////////////////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/np6e//////// + ////////////////////////////////////////////////////////////////////paWl/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2JiYv///////////////////////////52dnf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + ///////////////b29v/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/p6en///////////////////////////////////////////////////////////////////////8 + /Pz/UVFR/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/91dXX///////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/0lJSf/5+fn///////////// + /////////////////////////////////////////////////////////7Kysv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/85OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/89PT3/qamp/9PT0//U1NT/1NTU/9TU1P/U + 1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/y8vL/3R0dP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP+N + jY3/0NDQ/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/R0dH/k5OT/zU1Nf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/21tbf/Jycn/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U + 1NT/1NTU/9PT0/+urq7/QEBA/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//b29v///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////88PDz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5aWlv///////////////////////////25ubv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ZGRk////////////////////////////n5+f/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr/+Pj4//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + /////////////////////////////////////////////////////////1NTU/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////////////////////// + ////hYWF/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/97 + e3v///////////////////////////+2trb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0pKSv////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ////////////////////kJCQ/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zY2Nv/n5+f////////////////////////////CwsL/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/7m5uf///////////////////////////+3t7f86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/h4eH//////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + ///////////////////////////////////////////////////////////////s7Oz/RUVF/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/jIyM//////////////////////// + //////////39/f9lZWX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9e + Xl7/+/v7/////////////////////////////////5WVlf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0FBQf/n5+f///////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ///////////////////////////////S0tL/RERE/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/3d3d//5+fn//////////////////////////////////////+3t7f9cXFz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/V1dX/+np6f////////////////////////////////// + /////Pz8/39/f/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9CQkL/zMzM//////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ///////////////////////////////////////////////////////////////////////////////s + 7Oz/kJCQ/1JSUv89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/QkJC/2lpaf+6urr//v7+//////////////////////// + //////////////////////////n5+f+mpqb/Xl5e/z8/P/89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf8/Pz//XFxc/6Kiov/3 + 9/f//////////////////////////////////////////////////v7+/7+/v/9ra2v/Q0ND/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf9RUVH/jY2N/+np6f////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////39/f/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/+ + /v7///////////////////////////////////////////////////////////////////////////// + /////v7+//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//7+/v////////////////////////////////////////////////// + /////////////////////////////////////Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//f39//////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ///////////////////////////////IyMj/gICA/3Z2dv+lpaX/+Pj4//////////////////////// + /////////////////////////////////////////8rKyv+BgYH/dnZ2/6Ghof/19fX///////////// + ///////////////////////////////+/v7/4eHh/66urv+MjIz/eXl5/3Jycv94eHj/i4uL/66urv/k + 5OT///////////////////////////////////////////////////////////////////////z8/P/p + 6en/2NjY/9jY2P/k5OT/+Pj4////////////////////////////3t7e/4iIiP92dnb/pKSk//j4+P// + ///////////////////////////////////////////////////////////////////////////////x + 8fH/vLy8/5OTk/97e3v/c3Nz/3h4eP+Li4v/sLCw/+Xl5f////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ////////////////////////////////////////////////////////////////////t7e3/zU1Nf8z + MzP/MzMz/zMzM/9qamr//v7+//////////////////////////////////////////////////////+n + p6f/NTU1/zMzM/8zMzP/MzMz/15eXv/6+vr/////////////////////////////////wcHB/19fX/80 + NDT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zU1Nf9lZWX/yMjI//////////////////////// + //////////////////////////v7+/+dnZ3/SUlJ/zMzM/8zMzP/MzMz/zMzM/88PDz/b29v/9nZ2f// + /////////+Li4v8/Pz//MzMz/zMzM/8zMzP/cnJy//7+/v////////////////////////////////// + ///////////////////////////////o6Oj/hISE/zs7O/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NTU1/2lpaf/Pz8////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////9VVVX/MzMz/zMzM/8zMzP/MzMz/zMzM//Q0ND///////////// + ////////////////////////////////////0dHR/zc3N/8zMzP/MzMz/zMzM/8zMzP/MzMz/8fHx/// + ////////////////////9/f3/3t7e/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/80NDT/hoaG//r6+v//////////////////////////////////////hISE/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/RERE//f39///////kZGR/zMzM/8zMzP/MzMz/zMzM/8z + MzP/3d3d////////////////////////////////////////////////////////////vLy8/z8/P/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP+Pj4///Pz8//////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v/////////////////////////////////////////////////////////////////+/v7/zc3N/8z + MzP/MzMz/zMzM/8zMzP/MzMz/6+vr/////////////////////////////////////////////j4+P9U + VFT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/19fX//////////////////r6+v9sbGz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/f39///////// + /////////////////////////+bm5v82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/83 + Nzf/8PDw//////91dXX/MzMz/zMzM/8zMzP/MzMz/zMzM//CwsL///////////////////////////// + /////////////////////////7W1tf82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9/f3///v7+//////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ///////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////// + ////////////////////////////////////lJSU/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1hYWP/9 + /f3/////////////////lJSU/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/0xMTP+Hh4f/mZmZ/4eHh/9Q + UFD/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/yMjI////////////////////////////ubm5/zMzM/8z + MzP/MzMz/zMzM/82Njb/iIiI/5ubm/9/f3//ampq/7W1tf///////////3Nzc/8zMzP/MzMz/zMzM/8z + MzP/MzMz/7+/v//////////////////////////////////////////////////a2tr/OTk5/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0RERP9aWlr/S0tL/zQ0NP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+o + qKj//////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////j4+P82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz//////////////////////////////////////9jY2P84 + ODj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/t7e3/////////////////+bm5v85OTn/MzMz/zMzM/8z + MzP/MzMz/zMzM/+Ojo7/+fn5//////////////////z8/P+urq7/Pj4+/zMzM/8zMzP/MzMz/zMzM/+K + ior///////////////////////////+hoaH/MzMz/zMzM/8zMzP/MzMz/3d3d/////////////////// + ////////////////////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/v7+///////////////////////// + /////////////////////v7+/2lpaf8zMzP/MzMz/zMzM/8zMzP/MzMz/0JCQv+9vb3/+/v7///////+ + /v7/1tbW/1lZWf8zMzP/MzMz/zMzM/8zMzP/MzMz/0BAQP/w8PD///////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + ////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6ysrP// + ///////////////////////////////7+/v/XV1d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/15eXv/9 + /f3/////////////////l5eX/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//7+/v////////////////// + ///////////////d3d3/UFBQ/zMzM/8zMzP/MzMz/6Ojo////////////////////////////5eXl/8z + MzP/MzMz/zMzM/8zMzP/lZWV//////////////////////////////////////9zc3P/MzMz/zMzM/8z + MzP/MzMz/zMzM/+/v7/////////////////////////////////////////////b29v/NDQ0/zMzM/8z + MzP/MzMz/zMzM/82Njb/z8/P////////////////////////////8PDw/0tLS/8zMzP/MzMz/zMzM/8z + MzP/MzMz/6Wlpf////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra//////////////////////////////////////////////////////////////////4 + +Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys/////////////////////////////////6CgoP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/84ODj/1tbW//////////////////////9cXFz/MzMz/zMzM/8z + MzP/MzMz/zMzM//Kysr////////////////////////////////////////////u7u7/hISE/2dnZ/+X + l5f/+vr6////////////////////////////lZWV/zMzM/8zMzP/MzMz/zMzM/+ampr///////////// + /////////////////////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/7+/v/////////////////// + /////////////////////////5ycnP8zMzP/MzMz/zMzM/8zMzP/MzMz/3Nzc/////////////////// + ////////////////////q6ur/zMzM/8zMzP/MzMz/zMzM/8zMzP/ZmZm//////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+s + rKz////////////////////////////a2tr/Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5qamv// + ////////////////////+fn5/zw8PP8zMzP/MzMz/zMzM/8zMzP/NDQ0/7S0tP++vr7/vr6+/76+vv++ + vr7/vr6+/76+vv++vr7/vr6+/7+/v//Ly8v/6urq//////////////////////////////////////+V + lZX/MzMz/zMzM/8zMzP/MzMz/5qamv//////////////////////////////////////c3Nz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/vr6+////////////////////////////////////////////dHR0/zMzM/8z + MzP/MzMz/zMzM/8zMzP/sbGx///////////////////////////////////////p6en/MzMz/zMzM/8z + MzP/MzMz/zMzM/9AQED//Pz8////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + ////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6ysrP//////////////////////7+/v/1JSUv8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3//f39///////////////////////u7u7/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/81 + NTX/ZmZm/+rq6v///////////////////////////5WVlf8zMzP/MzMz/zMzM/8zMzP/mpqa//////// + //////////////////////////////9zc3P/MzMz/zMzM/8zMzP/MzMz/zMzM/+4uLj///////////// + //////////////////////////////9eXl7/MzMz/zMzM/8zMzP/MzMz/zMzM//Pz8////////////// + //////////////////////////////87Ozv/MzMz/zMzM/8zMzP/MzMz/zY2Nv/w8PD///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ///////////////////////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8z + MzP/rKys/////////////////+Dg4P9YWFj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/W1tb//Pz8/// + /////////////////////////+np6f8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/fHx8//////////////////////// + ////lZWV/zMzM/8zMzP/MzMz/zMzM/+ampr//////////////////////////////////////3Nzc/8z + MzP/MzMz/zMzM/8zMzP/MzMz/62trf///////////////////////////////////////////1lZWf8z + MzP/MzMz/zMzM/8zMzP/MzMz/9bW1v///////////////////////////////////////////0FBQf8z + MzP/MzMz/zMzM/8zMzP/NDQ0/+zs7P///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/9mZmb/iYmJ/4KCgv9lZWX/NjY2/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2JiYv/v7+//////////////////////////////////8/Pz/zQ0NP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9NTU3///////////////////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv// + ////////////////////////////////////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ//////// + ////////////////////////////////////ZGRk/zMzM/8zMzP/MzMz/zMzM/8zMzP/x8fH//////// + ///////////////////////////////7+/v/NjY2/zMzM/8zMzP/MzMz/zMzM/84ODj/9PT0//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + ////////////////////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/83Nzf/mpqa/+np6f// + ///////////////////////////////9/f3/RkZG/zMzM/8zMzP/MzMz/zMzM/8zMzP/m5ub/6urq/+r + q6v/q6ur/6urq/+rq6v/q6ur/6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/01NTf////////////////// + /////////5WVlf8zMzP/MzMz/zMzM/8zMzP/mpqa//////////////////////////////////////9z + c3P/MzMz/zMzM/8zMzP/MzMz/zMzM/92dnb///////////////////////////////////////////+A + gID/MzMz/zMzM/8zMzP/MzMz/zMzM/+enp7//////////////////////////////////////9fX1/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0pKSv////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ///////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5/4qKiv/09PT///////////////////////////9y + cnL/MzMz/zMzM/8zMzP/MzMz/zMzM/+zs7P/////////////////////////////////ysrK/zMzM/8z + MzP/MzMz/zMzM/8zMzP/aWlp////////////////////////////lZWV/zMzM/8zMzP/MzMz/zMzM/+a + mpr//////////////////////////////////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/0NDQ//5 + +fn//////////////////////////////////////7CwsP8zMzP/MzMz/zMzM/8zMzP/MzMz/1VVVf/8 + /Pz/////////////////////////////////ioqK/zMzM/8zMzP/MzMz/zMzM/8zMzP/enp6//////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////j4+P82Njb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/1dXV//r6+v//////////////////////7a2tv8zMzP/MzMz/zMzM/8zMzP/MzMz/1FRUf/z + 8/P///////////////////////v7+/9mZmb/MzMz/zMzM/8zMzP/MzMz/zMzM/+jo6P///////////// + //////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv////////////////////////////////// + ////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5ycnP/////////////////z8/P/0dHR/+7u7v// + ////7+/v/zs7O/8zMzP/MzMz/zMzM/8zMzP/MzMz/5iYmP/+/v7//////////////////////8fHx/84 + ODj/MzMz/zMzM/8zMzP/MzMz/zMzM//BwcH///////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2VlZf/+/v7///////////// + ////+fn5/05OTv8zMzP/MzMz/zMzM/8zMzP/MzMz/1lZWf/FxcX/8vLy//Pz8//Ozs7/aGho/zMzM/8z + MzP/MzMz/zMzM/8zMzP/Pz8//+/v7////////////////////////////5WVlf8zMzP/MzMz/zMzM/8z + MzP/mpqa//////////////////////////////////////9zc3P/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NDQ0/2RkZP99fX3/YmJi/zk5Of8zMzP/PDw8/7y8vP//////jIyM/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/3Jycv/CwsL/29vb/8zMzP+NjY3/ODg4/zMzM/8zMzP/MzMz/zMzM/8zMzP/W1tb//39/f// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ///////////////////////////////////////////////////////////////4+Pj/NjY2/zMzM/8z + MzP/MzMz/zMzM/8zMzP/pKSk//Ly8v/y8vL/8vLy//Ly8v/u7u7/4eHh/8XFxf+RkZH/Pz8//zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/8LCwv//////////////////////xMTE/zU1Nf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/82Njb/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+oqKj///////////// + ////9vb2/3Nzc/88PDz/NjY2/zMzM/8zMzP/MzMz/zMzM/82Njb/OTk5/0ZGRv92dnb/7e3t//////// + /////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/RUVF//v7+//z8/P/UFBQ/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zs7O//W1tb///////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz///////////// + ///////////////////////////////c3Nz/Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/f39///////// + ////////////////////paWl/zQ0NP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/hoaG//7+/v////////////////+2trb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+goKD/////////////////fn5+/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/+Pj4///////f39//SUlJ/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr/v7+///////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v/////////////////////////////////////////////////////////////////+Pj4/zY2Nv8z + MzP/MzMz/zMzM/8zMzP/MzMz/6ysrP////////////////////////////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/9cXFz/////////////////////////////////vLy8/0ZGRv8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Ozs7/6SkpP/+/v7///////////// + /////////9zc3P88PDz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NjY2/8nJyf// + //////////////+tra3/MzMz/zMzM/8zMzP/MzMz/0pKSv9dXV3/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/4eHh//////////////////q6ur/bW1t/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/VlZW/9TU1P////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ///////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////// + /////////////////////////////////////////3BwcP8zMzP/MzMz/zMzM/8zMzP/MzMz/1NTU/// + ////////////////////////////////////8vLy/6Kiov9aWlr/NTU1/zMzM/8zMzP/MzMz/zMzM/80 + NDT/SUlJ/46Ojv/m5ub//////////////////////////////////////+fn5/+4uLj/cHBw/zMzM/8z + MzP/MzMz/zMzM/9zc3P/srKy/7S0tP/d3d3///////////////////////j4+P9kZGT/MzMz/zMzM/84 + ODj/urq6//Dw8P9ycnL/NDQ0/zMzM/8zMzP/MzMz/01NTf+oqKj//Pz8///////////////////////+ + /v7/ysrK/3d3d/8+Pj7/MzMz/zMzM/8zMzP/MzMz/zMzM/84ODj/Z2dn/7W1tf/6+vr///////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////j4+P82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz////////////////////////////////////////////r + 6+v/Pz8//zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////////////////////////////////////// + ///////////////w8PD/0dHR/8HBwf++vr7/ysrK/+Xl5f/+/v7///////////////////////////// + //////////////////////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv////////////////// + //////////////////////////z8/P/Pz8//wMDA/+jo6P/////////////////k5OT/wsLC/8HBwf/d + 3d3//v7+//////////////////////////////////////////////////v7+//d3d3/xsbG/729vf/D + w8P/19fX//b29v////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + ////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6urq//9 + /f3//f39//39/f/9/f3//f39//v7+//z8/P/xMTE/1VVVf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+N + jY3///////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////5WVlf8z + MzP/MzMz/zMzM/8zMzP/mpqa//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra//////////////////////////////////////////////////////////////////4 + +Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/Pj4+/0VFRf9FRUX/RUVF/0VFRf9ERET/Pj4+/zQ0NP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0/9jY2P////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////lpaW/zMzM/8zMzP/MzMz/zMzM/+ampr///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////r6+v82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+C + goL///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////+c + nJz/MzMz/zMzM/8zMzP/MzMz/6Kiov////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////0tLS/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ampq//f39/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/zMzM/8zMzP/xMTE//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////oaGh/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/PT09/5iYmP/7 + +/v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////f39/4aGhv85OTn/Ozs7/4+Pj//+/v7///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + ///////////////9/f3/sLCw/2dnZ/9PT0//TU1N/01NTf9NTU3/TU1N/01NTf9NTU3/TU1N/01NTf9N + TU3/T09P/1ZWVv9lZWX/gICA/7CwsP/w8PD///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////X19f/29vb///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/Ozs7/zMzM/9fX1////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////9wcHD/MzMz/zMzM/89PT3cMzMz/z4+Pv/39/f///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////Pz8/0pKSv8z + MzP/OTk57Dw8PJUzMzP/MzMz/7W1tf////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////Gxsb/MzMz/zMzM/87OzuqS0tLNjQ0NP0zMzP/TExM//Hx8f// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////9/f3/1dXV/8z + MzP/MzMz/0VFRUmNjY0APDw8rTMzM/8zMzP/ZGRk//Hx8f////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////b29v9wcHD/MzMz/zMzM/86OjrAgYGBAgAAAABTU1MYNzc34zMzM/8z + MzP/TExM/7a2tv/39/f///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////r6+v++vr7/U1NT/zMzM/8z + MzP/NjY27E1NTSMAAAAAAAAAAAAAAABKSkoqNzc34jMzM/8zMzP/MzMz/z4+Pv9gYGD/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9iYmL/QUFB/zMzM/8zMzP/MzMz/zY2NutHR0c3AAAAAAAAAAAAAAAAAAAAAAAAAABT + U1MXPDw8rDQ0NP0zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP47 + Ozu5TU1NIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACNjY0AS0tLNTw8PJU9PT3bOzs7/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/Ojo6/0BAQOM8PDycR0dHPo+PjwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////////////////// + ////////////////////+AAAAAAAAAAAAAAAAAAAH+AAAAAAAAAAAAAAAAAAAAfAAAAAAAAAAAAAAAAA + AAADgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAHA + AAAAAAAAAAAAAAAAAAAD4AAAAAAAAAAAAAAAAAAAB/AAAAAAAAAAAAAAAAAAAA////////////////// + //////////////////////////////////////////////8oAAAAQAAAAIAAAAABACAAAAAAAABAAAAT + CwAAEwsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgICAAENDQyYzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQEJCQil0dHQAAAAAAAAAAAAAAAAAPz8/JTc3N8Iz + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Nzc3yD8/PyoA + AAAAQkJCGzU1NedWVlb/xMTE/+/v7//y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/v + 7+//x8fH/1paWv81NTXsQUFBIDg4OJxKSkr/8PDw//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////09PT/T09P/zc3N6Y4ODjum5ub//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////6Ojo/81NTX0MzMz/7W1tf// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////+9 + vb3/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////++vr7/MzMz/zMzM/+1 + tbX/////////////////////////////////29vb/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a + 2tr/2tra/9ra2v/a2tr/5+fn//////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////k5OT/2tra/9ra2v/a + 2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/g4OD//f39//////////////////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9wcHD/9vb2//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////aGho/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1RUVP/h + 4eH//////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////83 + Nzf/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5aWlv// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////2hoaP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/ZWVl//////////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ////////////////////Nzc3/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9mZmb///////////////////////////////////////////////////////////// + //////////////////////////////////////////////9oaGj/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zg4OP/8/Pz/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////////66urv+tra3/ra2t/62trf+tra3/ra2t/62trf+t + ra3/ra2t/62trf+RkZH/NDQ0/zMzM/8zMzP/Y2Nj//////////////////////////////////////// + ////////////////////////////////////////////////////////////////////wsLC/62trf+t + ra3/ra2t/62trf+tra3/ra2t/62trf+tra3/ra2t/6Ojo/9AQED/MzMz/zMzM/82Njb/+/v7//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////////////////////// + /////////////////////////////////////////1FRUf8zMzP/MzMz/2NjY/////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////hISE/zMzM/8z + MzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////// + //////////////////////////////////////////////////////////////9VVVX/MzMz/zMzM/9j + Y2P///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////4iIiP8zMzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8zMzP/tbW1//////// + //////////////////////////7+/v+ysrL/cnJy/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr//39/f// + ////VVVV/zMzM/8zMzP/Y2Nj/////////////////+/v7/+Pj4//bGxs/2tra/9ra2v/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/bGxs/46Ojv/t7e3//////////////////////87Ozv96enr/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/2tra//////+IiIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////////+enp7/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM//9/f3//////1VVVf8zMzP/MzMz/2NjY/////////////r6+v9WVlb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/U1NT//j4+P///////////8zMzP82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/8zMzP//////iIiI/zMzM/8zMzP/NjY2//v7+/// + //////////////++vr7/MzMz/zMzM/+1tbX/////////////////////////////////RkZG/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP//f39//////9VVVX/MzMz/zMzM/9jY2P////////////F + xcX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//A + wMD///////////93d3f/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//MzMz//////4iIiP8z + MzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + /////////zc3N/8zMzP/MzMz/zMzM/9GRkb/T09P/09PT/9PT0//T09P//39/f//////VVVV/zMzM/8z + MzP/Y2Nj////////////tra2/zMzM/8zMzP/MzMz/zU1Nf9PT0//UFBQ/1BQUP9QUFD/UFBQ/09PT/82 + Njb/MzMz/zMzM/8zMzP/sbGx////////////aGho/zMzM/8zMzP/MzMz/z8/P/9PT0//T09P/09PT/9P + T0//09PT//////+IiIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////83Nzf/MzMz/zMzM/9lZWX//f39//////////////////////// + /////////1VVVf8zMzP/MzMz/2NjY////////////7a2tv8zMzP/MzMz/zMzM/+wsLD///////////// + ////////////////////tLS0/zMzM/8zMzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/z8/P//x + 8fH/////////////////////////////////iIiI/zMzM/8zMzP/NjY2//v7+/////////////////++ + vr7/MzMz/zMzM/+1tbX/////////////////////////////////Nzc3/zMzM/8zMzP/goKC//////// + //////////////////////////////9VVVX/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8z + MzP/z8/P/////////////////////////////////9LS0v8zMzP/MzMz/zMzM/+xsbH///////////9o + aGj/MzMz/zMzM/9QUFD//////////////////////////////////////4iIiP8zMzP/MzMz/zY2Nv/7 + +/v/////////////////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8z + MzP/MzMz/4KCgv//////////////////////////////////////VVVV/zMzM/8zMzP/Y2Nj//////// + ////tra2/zMzM/8zMzP/MzMz/8/Pz//////////////////////////////////S0tL/MzMz/zMzM/8z + MzP/sbGx////////////aGho/zMzM/8zMzP/UFBQ//////////////////////////////////////+I + iIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////83Nzf/MzMz/zMzM/+CgoL//////////////////////////////////////1VVVf8z + MzP/MzMz/2NjY////////////7a2tv8zMzP/MzMz/zMzM//Pz8////////////////////////////// + ////0tLS/zMzM/8zMzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/1BQUP////////////////// + ////////////////////iIiI/zMzM/8zMzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1 + tbX/////////////////////////////////Nzc3/zMzM/8zMzP/goKC//////////////////////// + //////////////9VVVX/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8zMzP/z8/P//////// + /////////////////////////9LS0v8zMzP/MzMz/zMzM/+xsbH///////////9oaGj/MzMz/zMzM/9Q + UFD//////////////////////////////////////4iIiP8zMzP/MzMz/zY2Nv/7+/v///////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8zMzP/MzMz/4KCgv// + ////////////////////////////////////VVVV/zMzM/8zMzP/Y2Nj////////////tra2/zMzM/8z + MzP/MzMz/8/Pz//////////////////////////////////S0tL/MzMz/zMzM/8zMzP/sbGx//////// + ////aGho/zMzM/8zMzP/UFBQ//////////////////////////////////////+IiIj/MzMz/zMzM/82 + Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////83 + Nzf/MzMz/zMzM/93d3f//////////////////////////////////v7+/0tLS/8zMzP/MzMz/2NjY/// + /////////7a2tv8zMzP/MzMz/zMzM//ExMT/////////////////////////////////yMjI/zMzM/8z + MzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/0dHR//9/f3///////////////////////////// + ////fX19/zMzM/8zMzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ////////////////////Nzc3/zMzM/8zMzP/NjY2/3h4eP+EhIT/hISE/4SEhP+EhIT/hISE/2lpaf8z + MzP/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8zMzP/SkpK/4KCgv+EhIT/hISE/4SEhP+E + hIT/g4OD/0xMTP8zMzP/MzMz/zMzM/+xsbH///////////9oaGj/MzMz/zMzM/8zMzP/Z2dn/4SEhP+E + hIT/hISE/4SEhP+EhIT/enp6/zY2Nv8zMzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////////z09Pf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ampq////////////vLy8/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/t7e3////////////b29v/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr//f39//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////99fX3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/6qqqv///////////+/v7/9A + QED/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Pj4+/+zs7P// + /////////6+vr/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/eHh4//////////////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////// + ////9PT0/319ff89PT3/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/RERE/5iYmP/+ + /v7/////////////////0NDQ/1paWv84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84 + ODj/WVlZ/83Nzf/////////////////+/v7/nJyc/0VFRf84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84 + ODj/ODg4/zg4OP89PT3/e3t7//Ly8v//////////////////////vr6+/zMzM/8zMzP/tbW1//////// + //////////////////////////////////////////7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+/////////////////////////////////////////////v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+/////////////////////////////////////////////v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//////////////////////////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////++ + vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////Hx8f+9 + vb3/5+fn/////////////////////////////////9LS0v/FxcX//Pz8///////////////////////j + 4+P/wcHB/7q6uv/Ozs7/+Pj4//////////////////////////////////n5+f/r6+v/9/f3//////// + ////9/f3/7+/v//n5+f///////////////////////////////////////z8/P/T09P/u7u7/8DAwP/l + 5eX//////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////9dXV3/MzMz/0FBQf/z8/P//////////////////////6urq/8zMzP/MzMz/5SUlP// + /////////9zc3P9iYmL/MzMz/zMzM/8zMzP/MzMz/0BAQP+goKD//v7+/////////////////5SUlP85 + OTn/MzMz/zU1Nf9wcHD//f39/3l5ef8zMzP/Q0ND//b29v///////////////////////////7m5uf9J + SUn/MzMz/zMzM/8zMzP/MzMz/2hoaP/i4uL///////////////////////////++vr7/MzMz/zMzM/+1 + tbX////////////////////////////8/Pz/NTU1/zMzM/8zMzP/1tbW/////////////////+Li4v87 + Ozv/MzMz/zMzM/+YmJj//////+Pj4/9BQUH/MzMz/zMzM/9OTk7/YWFh/zo6Ov8zMzP/MzMz/56env// + /////////+fn5/80NDT/MzMz/0lJSf9gYGD/YmJi//v7+/9UVFT/MzMz/zMzM//g4OD///////////// + /////////7Kysv80NDT/MzMz/zMzM/9BQUH/OTk5/zMzM/8zMzP/RkZG/+np6f////////////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////Pz8/zQ0NP8zMzP/MzMz/9XV1f// + //////////7+/v9oaGj/MzMz/zMzM/8+Pj7/7e3t//////96enr/MzMz/zMzM/+Li4v//v7+///////q + 6ur/aGho/zMzM/9lZWX////////////Ozs7/MzMz/zMzM//Dw8P/////////////////U1NT/zMzM/8z + MzP/39/f//////////////////b29v9BQUH/MzMz/zQ0NP+zs7P//v7+//T09P9ycnL/MzMz/zMzM/+C + goL//////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////z8/P80 + NDT/MzMz/zMzM//V1dX///////////+tra3/MzMz/zMzM/8zMzP/qqqq///////+/v7/QEBA/zMzM/8z + MzP/z8/P/97e3v/e3t7/3t7e/9ra2v+oqKj/5OTk////////////ysrK/zMzM/8zMzP/zMzM//////// + /////////1NTU/8zMzP/MzMz/9/f3//////////////////Dw8P/MzMz/zMzM/9iYmL///////////// + ////5OTk/zMzM/8zMzP/Q0ND//7+/v////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ///////////////8/Pz/NDQ0/zMzM/8zMzP/1dXV///////Jycn/Ozs7/zMzM/8zMzP/e3t7//7+/v// + ////9fX1/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/4CAgP///////////8rKyv8z + MzP/MzMz/8zMzP////////////////9TU1P/MzMz/zMzM//Z2dn/////////////////ra2t/zMzM/8z + MzP/g4OD//////////////////////84ODj/MzMz/zQ0NP/39/f/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////Pz8/zQ0NP8zMzP/MzMz/1VVVf9TU1P/NDQ0/zMzM/8z + MzP/WVlZ//b29v////////////v7+/84ODj/MzMz/zMzM/9ra2v/b29v/29vb/9ubm7/MzMz/zMzM/9A + QED////////////Kysr/MzMz/zMzM//MzMz/////////////////U1NT/zMzM/8zMzP/w8PD//////// + /////////7i4uP8zMzP/MzMz/3Nzc//////////////////09PT/NDQ0/zMzM/86Ojr//Pz8//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////z8/P80NDT/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9KSkr/zc3N////////////ZGRk/zMzM/8zMzP/vb29//////// + ////ysrK/zMzM/8zMzP/XV1d////////////ysrK/zMzM/8zMzP/zMzM/////////////////1NTU/8z + MzP/MzMz/4ODg////////Pz8/+/v7//n5+f/NTU1/zMzM/88PDz/5OTk////////////oqKi/zMzM/8z + MzP/aGho//////////////////////++vr7/MzMz/zMzM/+1tbX////////////////////////////8 + /Pz/NDQ0/zMzM/8zMzP/f39//5KSkv+SkpL/g4OD/01NTf8zMzP/MzMz/z8/P//v7+///////8PDw/80 + NDT/MzMz/zw8PP+IiIj/i4uL/0BAQP8zMzP/MzMz/7W1tf//////2tra/4GBgf8zMzP/MzMz/4KCgv+u + rq7/+vr6//////9TU1P/MzMz/zMzM/8zMzP/UlJS/0BAQP81NTX/v7+//4GBgf8zMzP/MzMz/0NDQ/+B + gYH/cHBw/zQ0NP8zMzP/NTU1/8vLy///////////////////////vr6+/zMzM/8zMzP/tbW1//////// + /////////////////////Pz8/zQ0NP8zMzP/MzMz/9XV1f/////////////////29vb/Q0ND/zMzM/8z + MzP/tra2////////////paWl/zg4OP8zMzP/MzMz/zMzM/8zMzP/NTU1/5eXl////////////4CAgP8z + MzP/MzMz/zMzM/8zMzP/NDQ0/9ra2v//////ZGRk/zMzM/85OTn/Pj4+/zMzM/8zMzP/MzMz/66urv/3 + 9/f/dXV1/zMzM/8zMzP/MzMz/zMzM/8zMzP/PDw8/7Ozs////////////////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////z8/P80NDT/MzMz/zMzM//V1dX///////////// + ////+vr6/0VFRf8zMzP/MzMz/62trf/////////////////l5eX/n5+f/35+fv97e3v/mJiY/9zc3P// + ///////////////5+fn/r6+v/zMzM/8zMzP/sLCw/+Tk5P///////////9bW1v99fX3/tra2/9jY2P+D + g4P/gYGB/7y8vP/+/v7////////////Q0ND/k5OT/3p6ev+AgID/paWl/+vr6/////////////////// + //////////////++vr7/MzMz/zMzM/+1tbX////////////////////////////8/Pz/NDQ0/zMzM/8z + MzP/i4uL/6Ghof+hoaH/mJiY/2BgYP8zMzP/MzMz/zMzM//Z2dn///////////////////////////// + /////////////////////////////////////////8rKyv8zMzP/MzMz/83Nzf////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + /////v7+/zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+FhYX///////////// + ///////////////////////////////////////////////////////////////W1tb/MzMz/zMzM//Z + 2dn///////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////+goKD/R0dH/0BAQP9AQED/QEBA/0BAQP9AQED/SEhI/2VlZf+x + sbH//v7+//////////////////////////////////////////////////////////////////////// + /////v7+/62trf+wsLD///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////++ + vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////++vr7/MzMz/zMzM/+1 + tbX///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////+9vb3/MzMz/zc3N/alpaX///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////ra2t/zQ0NPo3 + NzeyWlpa//v7+/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////f39/2FhYf83Nze8Pj4+MTQ0NPh1dXX/6+vr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////7e3t/3t7e/80NDT6Pj4+OQAAAAA8PDxJNTU16jY2Nv9MTEz/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/TU1N/zY2Nv81NTXtOzs7UAAAAAAAAAAAAAAAAEtLSw09 + PT1cNzc3gDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDc3N4A+Pj5gSEhIEAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAP//////////wAAAAAAAAAOAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAHAAAAAAAAAA///////////KAAAADAAAABg + AAAAAQAgAAAAAAAAJAAAEwsAABMLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9PT0lNzc3kTMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzY2NpQ8PDwoAAAAADs7OzM6 + OjrvkpKS/8DAwP/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/C + wsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/C + wsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wcHB/5WVlf87 + OzvxOzs7ODY2NsGtra3///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////+0tLT/NjY2yTs7O/zv7+////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////z8/P/Ozs7/jo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////01NTf9KSkr/SkpK/0pKSv9KSkr/SkpK/0pKSv9K + Skr/SkpK/11dXf/MzMz///////////////////////////////////////////////////////////// + ////////////////////m5ub/0pKSv9KSkr/SkpK/0pKSv9KSkr/SkpK/0pKSv9KSkr/Tk5O/5CQkP/8 + /Pz////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9ISEj/+vr6//////////////////////////////////////// + ////////////////////////////////////jo6O/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+2trb////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////15eXv9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9YWFj/NTU1/zMzM/80NDT/7+/v//////////////////////// + ////////////////////////////////////////////////////pKSk/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9DQ0P/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + ////////////////////////////////////////////////////dHR0/zMzM/80NDT/7+/v//////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////MzMz/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT////////////////////////////6+vr/8/Pz//Pz8//z8/P/8/Pz//b29v//////gICA/zMzM/80 + NDT/7+/v/////////////Pz8//T09P/z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//29vb///////////// + //////////7+/v/09PT/8/Pz//Pz8//z8/P/8/Pz//39/f/Z2dn/MzMz/zMzM/+YmJj////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////8HBwf9HR0f/NjY2/zY2Nv82Njb/NjY2/2dnZ/// + ////gICA/zMzM/80NDT/7+/v///////e3t7/VVVV/zY2Nv82Njb/NjY2/zY2Nv82Njb/NjY2/zY2Nv88 + PDz/kJCQ////////////8/Pz/2pqav84ODj/NjY2/zY2Nv82Njb/NjY2/9nZ2f/Z2dn/MzMz/zMzM/+Y + mJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////0pKSv8zMzP/MzMz/zMzM/8z + MzP/MzMz/2VlZf//////gICA/zMzM/80NDT/7+/v//////92dnb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/9jY2P//////oqKi/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9nZ2f/Z + 2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8z + MzP/NjY2/3V1df97e3v/e3t7/5ubm///////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/zMzM/9o + aGj/fHx8/3x8fP98fHz/e3t7/0ZGRv8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/WVlZ/3t7e/97 + e3v/e3t7/+bm5v/Z2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////////zY2Nv8zMzP/ampq////////////////////////////gICA/zMzM/80NDT/7+/v//////9i + YmL/MzMz/0FBQf/7+/v//////////////////////6ampv8zMzP/MzMz/8TExP//////jo6O/zMzM/8z + MzP/3d3d///////////////////////Z2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////////zY2Nv8zMzP/bm5u////////////////////////////gICA/zMzM/80 + NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7//////////////////////6urq/8zMzP/MzMz/8TExP// + ////jo6O/zMzM/8zMzP/4uLi///////////////////////Z2dn/MzMz/zMzM/+YmJj////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/bm5u//////////////////////// + ////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7//////////////////////6urq/8z + MzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/4uLi///////////////////////Z2dn/MzMz/zMzM/+Y + mJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/bm5u//////// + ////////////////////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7///////////// + /////////6urq/8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/4uLi///////////////////////Z + 2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8z + MzP/aGho////////////////////////////enp6/zMzM/80NDT/7+/v//////9iYmL/MzMz/0BAQP/7 + +/v//////////////////////6Wlpf8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/29vb//////// + ///////////////S0tL/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////////zY2Nv8zMzP/NDQ0/2lpaf9vb2//b29v/29vb/9ra2v/Nzc3/zMzM/80NDT/7+/v//////9i + YmL/MzMz/zMzM/9dXV3/b29v/29vb/9vb2//b29v/0FBQf8zMzP/MzMz/8TExP//////jo6O/zMzM/8z + MzP/UFBQ/29vb/9vb2//b29v/29vb/9OTk7/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////////01NTf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9D + Q0P/+Pj4//////95eXn/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0/9vb2/// + ////paWl/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+wsLD////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////8nJyf9NTU3/Nzc3/zc3N/83Nzf/Nzc3/zc3N/83 + Nzf/Nzc3/0hISP+9vb3////////////j4+P/Xl5e/zc3N/83Nzf/Nzc3/zc3N/83Nzf/Nzc3/zc3N/8/ + Pz//m5ub////////////9vb2/3R0dP85OTn/Nzc3/zc3N/83Nzf/Nzc3/zc3N/83Nzf/OTk5/3t7e//4 + +Pj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////////////////v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+//////////////////////////////////7+/v/+/v7//v7+//7+/v/+ + /v7//v7+//7+/v/////////////////////////////////+/v7//v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+///////////////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT///////////////////////7+/v/6+vr////////////////////////////5 + +fn///////////////////////7+/v/39/f//Pz8//////////////////////////////////////// + //////////7+/v/6+vr//////////////////////////////////v7+//b29v/9/f3///////////// + ///////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////2xsbP9ERET/2dnZ//////// + /////////6ampv8+Pj7/lJSU///////6+vr/mJiY/01NTf88PDz/QUFB/3Z2dv/j4+P////////////g + 4OD/bm5u/1hYWP90dHT/6+vr/39/f/9ERET/3Nzc//////////////////v7+/+bm5v/TU1N/zs7O/9F + RUX/goKC/+/v7//////////////////4+Pj/PDw8/zo6Ov/09PT//////////////////f39/zQ0NP8z + MzP/ra2t////////////2tra/zg4OP8zMzP/ioqK//////9+fn7/MzMz/0ZGRv+Hh4f/ZmZm/zQ0NP9M + TEz/+Pj4//////+Dg4P/MzMz/29vb/+BgYH/5eXl/0tLS/8zMzP/tLS0/////////////////4GBgf8z + MzP/Pj4+/3BwcP9JSUn/MzMz/1lZWf/4+Pj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////Pz8/zQ0NP8zMzP/ra2t///////7+/v/YGBg/zMzM/9AQED/5+fn/+Xl5f82Njb/MzMz/9bW1v// + /////v7+/6mpqf9vb2//9/f3//////9ycnL/MzMz/9fX1////////////0tLS/8zMzP/tLS0//////// + ////5ubm/zQ0NP80NDT/y8vL///////s7Oz/QUFB/zMzM/+5ubn////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////Pz8/zQ0NP8zMzP/ra2t//39/f+Pj4//MzMz/zU1Nf+4uLj//////8fHx/8z + MzP/MzMz/01NTf9OTk7/Tk5O/09PT/9wcHD/9fX1//////9xcXH/MzMz/9nZ2f///////////0tLS/8z + MzP/sLCw////////////w8PD/zMzM/9AQED/+fn5////////////Z2dn/zMzM/+VlZX////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8zMzP/RkZG/0ZGRv8zMzP/MzMz/2pqav/x + 8fH//////9LS0v8zMzP/MzMz/4uLi/+Tk5P/jo6O/zMzM/8zMzP/2NjY//////9xcXH/MzMz/9nZ2f// + /////////0tLS/8zMzP/mZmZ////////////z8/P/zMzM/84ODj/7u7u///////+/v7/V1dX/zMzM/+h + oaH////////////4+Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8zMzP/QEBA/0lJSf9G + Rkb/NjY2/zMzM/9NTU3/6enp//j4+P9LS0v/MzMz/4yMjP/f39//lZWV/zMzM/9FRUX/9vb2//b29v9q + amr/MzMz/8bGxv/5+fn//////0tLS/8zMzP/S0tL/6+vr/+SkpL/z8/P/0lJSf8zMzP/enp6/9PT0/+b + m5v/NDQ0/zg4OP/e3t7////////////4+Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8z + MzP/rKys//39/f/8/Pz/4ODg/zw8PP8zMzP/nJyc///////Ly8v/QUFB/zMzM/8zMzP/MzMz/z09Pf/C + wsL//////3R0dP8zMzP/MzMz/zQ0NP+Ghob//////1VVVf8zMzP/PDw8/zMzM/8zMzP/jIyM/83Nzf9D + Q0P/MzMz/zMzM/8zMzP/ODg4/62trf/////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////Pz8/zQ0NP8zMzP/ra2t////////////6+vr/z09Pf8zMzP/kpKS////////////8PDw/7Kysv+b + m5v/ra2t/+zs7P////////////X19f9oaGj/MzMz/8LCwv/39/f//////9HR0f+oqKj/5eXl/6CgoP+z + s7P/+Pj4///////y8vL/tLS0/5ubm/+rq6v/5ubm///////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////f39/zU1Nf8zMzP/RkZG/1NTU/9RUVH/PT09/zMzM/83Nzf/0tLS//////// + //////////////////////////////////////////////90dHT/MzMz/9vb2/////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////3l5ef8+Pj7/PT09/z09Pf89PT3/QkJC/2FhYf/B + wcH////////////////////////////////////////////////////////////FxcX/i4uL//r6+v// + //////////////////////////////////////////////////////////////////////////////// + ///////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zs7O/7x8fH///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////19fX/Ozs7/zY2NtC/v7////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////FxcX/NjY21zo6OkpDQ0P5srKy/+Dg4P/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4ODg/7W1tf9FRUX7OTk5UHFxcQA6OjpCNjY2uTQ0NN8z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfNDQ03zc3N7w6OjpGa2trAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAP///////wAAgAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////8AACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAABMLAAAT + CwAAAAAAAAAAAAAAAAAARERECjMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAz + MzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAz + MzMgMzMzIDMzMyBDQ0MKAAAAADc3N0pjY2PwkpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+S + kpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+S + kpL/kpKS/5KSkv+SkpL/kpKS/2VlZfE3NzdOWVlZ4vv7+/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////Pz8/1tbW+Z0dHT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////eHh4/3R0dP////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////94eHj/dHR0//////// + /////////4iIiP+Hh4f/h4eH/4eHh/+Hh4f/h4eH/6Kiov/9/f3///////////////////////////// + ////////////////////0tLS/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/iIiI/8zMzP///////////3h4eP90 + dHT/////////////////NTU1/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/7+/v/////////////////// + //////////////////////////////+0tLT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/QUFB//7+/v// + ////eHh4/3R0dP/////////////////W1tb/1tbW/9bW1v/W1tb/1tbW/4WFhf8zMzP/sbGx//////// + //////////////////////////////////////////Dw8P/W1tb/1tbW/9bW1v/W1tb/09PT/0pKSv81 + NTX//f39//////94eHj/dHR0/////////////////+zs7P+3t7f/tbW1/7W1tf/a2tr/qqqq/zMzM/+x + sbH//////9/f3/+1tbX/tbW1/7W1tf+1tbX/tbW1/97e3v///////////9HR0f+1tbX/tbW1/7W1tf/2 + 9vb/XV1d/zU1Nf/9/f3//////3h4eP90dHT/////////////////UlJS/zMzM/8zMzP/MzMz/5iYmP+q + qqr/MzMz/7Gxsf/v7+//PDw8/zMzM/8zMzP/MzMz/zMzM/8zMzP/Ozs7/+7u7v/Q0ND/NDQ0/zMzM/8z + MzP/MzMz/+Xl5f9dXV3/NTU1//39/f//////eHh4/3R0dP////////////////81NTX/QEBA/6SkpP+n + p6f/09PT/6qqqv8zMzP/sbGx/9ra2v8zMzP/U1NT/6enp/+np6f/p6en/1RUVP8zMzP/2NjY/7S0tP8z + MzP/aGho/6enp/+np6f/9PT0/11dXf81NTX//f39//////94eHj/dHR0/////////////////zU1Nf9b + W1v/////////////////qqqq/zMzM/+xsbH/2tra/zMzM/+BgYH/////////////////g4OD/zMzM//Y + 2Nj/tLS0/zMzM/+np6f/////////////////XV1d/zU1Nf/9/f3//////3h4eP90dHT///////////// + ////NTU1/1tbW/////////////////+qqqr/MzMz/7Gxsf/a2tr/MzMz/4GBgf////////////////+D + g4P/MzMz/9jY2P+0tLT/MzMz/6enp/////////////////9dXV3/NTU1//39/f//////eHh4/3R0dP// + //////////////81NTX/WFhY/////////////////6enp/8zMzP/sbGx/9ra2v8zMzP/fn5+//////// + /////////4CAgP8zMzP/2NjY/7S0tP8zMzP/paWl/////////////////1tbW/81NTX//f39//////94 + eHj/dHR0/////////////////zY2Nv80NDT/WVlZ/1tbW/9bW1v/QUFB/zMzM/+zs7P/3Nzc/zMzM/85 + OTn/W1tb/1tbW/9bW1v/OTk5/zMzM//Z2dn/tbW1/zMzM/9AQED/W1tb/1tbW/9ZWVn/NDQ0/zY2Nv/9 + /f3//////3h4eP90dHT/////////////////iIiI/zc3N/81NTX/NTU1/zU1Nf81NTX/UVFR/+np6f/7 + +/v/Z2dn/zY2Nv81NTX/NTU1/zU1Nf82Njb/ZmZm//r6+v/r6+v/UlJS/zU1Nf81NTX/NTU1/zU1Nf83 + Nzf/hoaG////////////eHh4/3R0dP////////////////////////////7+/v/+/v7//v7+//7+/v// + /////////////////////v7+//7+/v/+/v7//v7+//7+/v///////////////////////v7+//7+/v/+ + /v7//v7+//////////////////////94eHj/dHR0//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3h4eP90dHT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////eHh4/3R0dP////////////////+Q + kJD/xsbG////////////rKys/6Kiov//////z8/P/4KCgv97e3v/tra2////////////sbGx/5OTk//b + 29v/mZmZ/8jIyP///////////+3t7f+Tk5P/eHh4/6CgoP/4+Pj///////////94eHj/dHR0//////// + /////f39/zQ0NP+EhIT//////9LS0v81NTX/fX19/9fX1/83Nzf/g4OD/6Ghof9AQED/wMDA/+3t7f8z + MzP/m5ub/9fX1/9DQ0P/iYmJ///////9/f3/VlZW/1NTU/+bm5v/Q0ND/3l5ef///////////3h4eP90 + dHT////////////9/f3/NDQ0/4SEhP/y8vL/U1NT/0VFRf/q6ur/mZmZ/zMzM/+FhYX/iYmJ/3p6ev/Y + 2Nj/5OTk/zMzM//m5ub//////0NDQ/+Hh4f//////9zc3P8zMzP/ubm5//////+UlJT/Nzc3//39/f// + ////eHh4/3R0dP////////////39/f80NDT/PDw8/zs7O/8zMzP/c3Nz//Pz8/+lpaX/MzMz/6ampv+q + qqr/MzMz/6enp//k5OT/MzMz/+bm5v//////Q0ND/2tra//+/v7/4+Pj/zMzM/+kpKT//////39/f/9C + QkL//v7+//////94eHj/dHR0/////////////f39/zQ0NP9vb2//ycnJ/7Gxsf83Nzf/hoaG//Dw8P9R + UVH/S0tL/0xMTP9NTU3/7Ozs/4SEhP8zMzP/ZmZm//X19f9HR0f/Nzc3/z4+Pv91dXX/iIiI/zc3N/9W + Vlb/NTU1/6ysrP///////////3h4eP90dHT////////////9/f3/NDQ0/3Fxcf/Q0ND/vLy8/zg4OP97 + e3v///////j4+P/Hx8f/xMTE//b29v//////3Nzc/zMzM//Y2Nj//////9TU1P/j4+P/wcHB/+7u7v// + ////2NjY/76+vv/k5OT/////////////////eHh4/3R0dP////////////////9VVVX/OTk5/zk5Of88 + PDz/X19f/+Dg4P/////////////////////////////////19fX/cXFx//X19f////////////////// + //////////////////////////////////////////////94eHj/dHR0//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3h4eP90dHT///////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////eHh4/3R0dP// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////94 + eHj/X19f6v7+/v////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////v7+/2FhYe03NzdcdHR0+qampv+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+n + p6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+n + p6f/p6en/6enp/92dnb7Nzc3YQAAAAA+Pj4aNTU1QDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNANTU1QEBAQBwAAAAAgAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAEoAAAAEAAAACAAAAABACAAAAAAAAAEAAAT + CwAAEwsAAAAAAAAAAAAAWFhYUYeHh4+IiIiPiIiIj4iIiI+IiIiPiIiIj4iIiI+IiIiPiIiIj4iIiI+I + iIiPiIiIj4iIiI+IiIiPWVlZUrS0tPj///////////////////////////////////////////////// + /////////////////////////7a2tvm6urr//////8PDw//Dw8P/w8PD/+fn5/////////////////// + ////9PT0/8PDw//Dw8P/w8PD//Ly8v+8vLz/urq6//////+FhYX/hISE/3BwcP91dXX///////////// + /////////+jo6P+EhIT/hISE/2FhYf+cnJz/vLy8/7q6uv//////ioqK/3R0dP+ysrL/cnJy/8LCwv90 + dHT/dHR0/4CAgP/v7+//e3t7/3R0dP+lpaX/mZmZ/7y8vP+6urr//////0FBQf/S0tL/ycnJ/3Jycv+H + h4f/n5+f/9PT0/9PT0//xsbG/11dXf/T09P/q6ur/5mZmf+8vLz/urq6//////9HR0f//////9TU1P9y + cnL/h4eH/7+/v///////Wlpa/8bGxv9tbW3//////62trf+ZmZn/vLy8/7q6uv//////SkpK/0hISP9C + QkL/iIiI/5ycnP9AQED/SEhI/0JCQv/d3d3/Pz8//0hISP8+Pj7/rq6u/7y8vP+6urr///////////// + //////////////////////////////////////////////////////////////+8vLz/urq6///////V + 1dX//////9PT0//z8/P/v7+//+3t7f/s7Oz/29vb/9jY2P//////4ODg/8XFxf/9/f3/vLy8/7q6uv/+ + /v7/XFxc/8XFxf94eHj/dnZ2/4yMjP+VlZX/jo6O/9bW1v9mZmb/9vb2/2VlZf+cnJz/q6ur/7y8vP+6 + urr//v7+/0RERP96enr/iYmJ/4aGhv96enr/hYWF/3R0dP/Q0ND/S0tL/6Wlpf9mZmb/goKC/7u7u/+8 + vLz/urq6//7+/v9NTU3/gICA/3x8fP/9/f3/4uLi//39/f+dnZ3/8/Pz/+3t7f/r6+v/9fX1/+jo6P// + ////vLy8/7q6uv////////////////////////////////////////////////////////////////// + /////////7y8vP+2trb6//////////////////////////////////////////////////////////// + //////////////+3t7f7YWFhXJCQkJ+QkJCfkJCQn5CQkJ+QkJCfkJCQn5CQkJ+QkJCfkJCQn5CQkJ+Q + kJCfkJCQn5CQkJ+QkJCfYmJiXgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + + + \ No newline at end of file diff --git a/src/RetroGOG/frmMain.Designer.cs b/src/RetroGOG/frmMain.Designer.cs new file mode 100644 index 0000000..2dddf09 --- /dev/null +++ b/src/RetroGOG/frmMain.Designer.cs @@ -0,0 +1,197 @@ +namespace RetroGOG +{ + partial class frmMain + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(frmMain)); + this.btnNext = new System.Windows.Forms.Button(); + this.pictureBox1 = new System.Windows.Forms.PictureBox(); + this.pictureBox2 = new System.Windows.Forms.PictureBox(); + this.pictureBox3 = new System.Windows.Forms.PictureBox(); + this.btnCancel = new System.Windows.Forms.Button(); + this.btnAbout = new System.Windows.Forms.Button(); + this.lblWelcom = new System.Windows.Forms.Label(); + this.lblExplain = new System.Windows.Forms.Label(); + this.lblExplain2 = new System.Windows.Forms.Label(); + this.lblExplain3 = new System.Windows.Forms.Label(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox2)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox3)).BeginInit(); + this.SuspendLayout(); + // + // btnNext + // + this.btnNext.Location = new System.Drawing.Point(462, 343); + this.btnNext.Name = "btnNext"; + this.btnNext.Size = new System.Drawing.Size(75, 23); + this.btnNext.TabIndex = 0; + this.btnNext.Text = "&Next > >"; + this.btnNext.UseVisualStyleBackColor = true; + this.btnNext.Click += new System.EventHandler(this.btnNext_Click); + // + // pictureBox1 + // + this.pictureBox1.Image = global::RetroGOG.Properties.Resources.retroarch; + this.pictureBox1.InitialImage = global::RetroGOG.Properties.Resources.retroarch; + this.pictureBox1.Location = new System.Drawing.Point(12, 131); + this.pictureBox1.Name = "pictureBox1"; + this.pictureBox1.Size = new System.Drawing.Size(193, 192); + this.pictureBox1.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.pictureBox1.TabIndex = 1; + this.pictureBox1.TabStop = false; + // + // pictureBox2 + // + this.pictureBox2.Image = global::RetroGOG.Properties.Resources.galaxy_logo; + this.pictureBox2.Location = new System.Drawing.Point(425, 131); + this.pictureBox2.Name = "pictureBox2"; + this.pictureBox2.Size = new System.Drawing.Size(193, 192); + this.pictureBox2.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.pictureBox2.TabIndex = 2; + this.pictureBox2.TabStop = false; + // + // pictureBox3 + // + this.pictureBox3.BackColor = System.Drawing.Color.Transparent; + this.pictureBox3.Image = global::RetroGOG.Properties.Resources.arrow; + this.pictureBox3.Location = new System.Drawing.Point(211, 131); + this.pictureBox3.Name = "pictureBox3"; + this.pictureBox3.Size = new System.Drawing.Size(208, 134); + this.pictureBox3.TabIndex = 3; + this.pictureBox3.TabStop = false; + // + // btnCancel + // + this.btnCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.btnCancel.Location = new System.Drawing.Point(543, 343); + this.btnCancel.Name = "btnCancel"; + this.btnCancel.Size = new System.Drawing.Size(75, 23); + this.btnCancel.TabIndex = 4; + this.btnCancel.Text = "&Cancel"; + this.btnCancel.UseVisualStyleBackColor = true; + this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click); + // + // btnAbout + // + this.btnAbout.Location = new System.Drawing.Point(12, 343); + this.btnAbout.Name = "btnAbout"; + this.btnAbout.Size = new System.Drawing.Size(75, 23); + this.btnAbout.TabIndex = 5; + this.btnAbout.Text = "&About"; + this.btnAbout.UseVisualStyleBackColor = true; + this.btnAbout.Click += new System.EventHandler(this.btnAbout_Click); + // + // lblWelcom + // + this.lblWelcom.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.lblWelcom.Font = new System.Drawing.Font("Microsoft Sans Serif", 14.25F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblWelcom.Location = new System.Drawing.Point(-2, 9); + this.lblWelcom.Name = "lblWelcom"; + this.lblWelcom.Size = new System.Drawing.Size(630, 24); + this.lblWelcom.TabIndex = 6; + this.lblWelcom.Text = "Welcome to the RetroGOG Installation Wizard"; + this.lblWelcom.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // lblExplain + // + this.lblExplain.AutoSize = true; + this.lblExplain.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblExplain.Location = new System.Drawing.Point(12, 45); + this.lblExplain.Name = "lblExplain"; + this.lblExplain.Size = new System.Drawing.Size(616, 18); + this.lblExplain.TabIndex = 7; + this.lblExplain.Text = "This wizard will guide you through the download, installation, and configuration " + + "of RetroGOG."; + // + // lblExplain2 + // + this.lblExplain2.AutoSize = true; + this.lblExplain2.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblExplain2.Location = new System.Drawing.Point(12, 63); + this.lblExplain2.Name = "lblExplain2"; + this.lblExplain2.Size = new System.Drawing.Size(536, 18); + this.lblExplain2.TabIndex = 8; + this.lblExplain2.Text = "RetroGOG allows you to play your Retroarch games directly in GOG Galaxy 2.0."; + // + // lblExplain3 + // + this.lblExplain3.AutoSize = true; + this.lblExplain3.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblExplain3.Location = new System.Drawing.Point(12, 91); + this.lblExplain3.Name = "lblExplain3"; + this.lblExplain3.Size = new System.Drawing.Size(158, 18); + this.lblExplain3.TabIndex = 9; + this.lblExplain3.Text = "To begin, press \"Next\"."; + // + // frmMain + // + this.AcceptButton = this.btnNext; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.CancelButton = this.btnCancel; + this.ClientSize = new System.Drawing.Size(630, 378); + this.Controls.Add(this.lblExplain3); + this.Controls.Add(this.lblExplain2); + this.Controls.Add(this.lblExplain); + this.Controls.Add(this.lblWelcom); + this.Controls.Add(this.btnAbout); + this.Controls.Add(this.btnCancel); + this.Controls.Add(this.pictureBox3); + this.Controls.Add(this.pictureBox2); + this.Controls.Add(this.pictureBox1); + this.Controls.Add(this.btnNext); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon"))); + this.MaximizeBox = false; + this.Name = "frmMain"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "RetroGOG"; + this.Load += new System.EventHandler(this.frmMain_Load); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox2)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox3)).EndInit(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Button btnNext; + private System.Windows.Forms.PictureBox pictureBox1; + private System.Windows.Forms.PictureBox pictureBox2; + private System.Windows.Forms.PictureBox pictureBox3; + private System.Windows.Forms.Button btnCancel; + private System.Windows.Forms.Button btnAbout; + private System.Windows.Forms.Label lblWelcom; + private System.Windows.Forms.Label lblExplain; + private System.Windows.Forms.Label lblExplain2; + private System.Windows.Forms.Label lblExplain3; + } +} \ No newline at end of file diff --git a/src/RetroGOG/frmMain.cs b/src/RetroGOG/frmMain.cs new file mode 100644 index 0000000..d737ba5 --- /dev/null +++ b/src/RetroGOG/frmMain.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace RetroGOG +{ + public partial class frmMain : Form + { + public frmMain() + { + InitializeComponent(); + } + + private void frmMain_Load(object sender, EventArgs e) + { + + } + + private void btnCancel_Click(object sender, EventArgs e) + { + if (MessageBox.Show("Are you sure you want to exit the wizard? Any unsaved progress will be lost.", "RetroGOG", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1) == System.Windows.Forms.DialogResult.Yes) + { + this.Close(); + } + } + + private void btnAbout_Click(object sender, EventArgs e) + { + Form frmAbout = new frmAbout(); + frmAbout.Show(); + } + + private void btnNext_Click(object sender, EventArgs e) + { + this.Hide(); + Form frmDependencies = new frmDependencies(); + frmDependencies.Closed += (s, args) => this.Close(); + frmDependencies.Show(); + } + } +} diff --git a/src/RetroGOG/frmMain.resx b/src/RetroGOG/frmMain.resx new file mode 100644 index 0000000..accb675 --- /dev/null +++ b/src/RetroGOG/frmMain.resx @@ -0,0 +1,1889 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + 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 + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAICAgAFPT08sPz8/bjMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4A+Pj5xTU1NMnR0dAIA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAImJiQBHR0dEOTk5xzQ0NP4zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk50UVFRVGOjo4AAAAAAAAAAAAAAAAAAAAAAAAAAABs + bGwEPj4+jzQ0NP4zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/z4+PqFdXV0IAAAAAAAAAAAAAAAAZmZmAT09PZ8zMzP/MzMz/zMzM/9iYmL/r6+v/9jY2P/k + 5OT/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5OTk/9ra2v+zs7P/aWlp/zMzM/8zMzP/MzMz/zw8PLJ6enoEAAAAAAAAAABC + QkJqMzMz/zMzM/88PDz/tbW1//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////wcHB/0FBQf8zMzP/MzMz/z8/P34AAAAAWlpaEjY2Nu4zMzP/NjY2/8XFxf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////0tLS/zo6Ov8zMzP/NTU19lFRUR8/ + Pz9xMzMz/zMzM/+Kior///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////m5ub/zMzM/8zMzP/Pj4+hjo6OsAzMzP/NTU1/+Xl5f////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////w8PD/Ozs7/zMzM/85 + OTnVQUFB+zMzM/9TU1P///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////9jY2P/MzMz/zY2Nv01NTX/MzMz/2lpaf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3p6ev8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ////////////////////////////////////uLi4/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/vb29/+Li4v////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////9zc3P+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7e3t//MzMz/9/f3//////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NTU1/2lpaf/h4eH///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9DQ0P/p6en//7+/v////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0NDQ//c3Nz///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/iYmJ//////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1tbW//+ + /v7///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////52dnf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/wsLC//////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/83Nzf////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9qamr/////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/n5+f//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/z8/P//8/Pz///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//f39////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////9hYWH/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1FRUf80NDT/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5SUlP////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////sLCw/1tbW/9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9aWlr/Pz8//zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////8DAwP81NTX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////z8/P/Z2dn/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////2hoaP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+UlJT///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////Nzc3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////9zc3P8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////94eHj/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/lJSU//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/2 + 9vb///////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////c + 3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + ///////////////////////////////////////////////x8fH/vLy8/6Wlpf+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo//9/f3/////////////////eHh4/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////////////////////////////////r6+v/I + yMj/qKio/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/o6Oj/6enp//FxcX/+Pj4//////////////////////////////////////// + /////////////////////v7+/9XV1f+tra3/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/0dHR/////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85 + OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v////////////////////////////////////////////////////////////////////////////9 + /f3/nJyc/z09Pf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NDQ0//v7+/////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////// + //////////////////////////////++vr7/SkpK/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9G + Rkb/t7e3/////////////////////////////////////////////////9vb2/9eXl7/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+ZmZn///////////// + ////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + /////////////////////////////////////v7+/4SEhP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/80NDT/+/v7/////////////////3h4eP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT/////////////////////////////////s7Oz/zU1Nf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/80NDT/q6ur//////////////////////// + ///////////////a2tr/QEBA/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5mZmf/////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/OTk5//b29v///////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr///////////////////////////////////////////////////////////////////////C + wsL/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zQ0NP/7+/v/////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP// + /////////////////////////+rq6v89PT3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/86Ojr/5OTk/////////////////////////////f39/1tbW/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ//////// + /////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////25ubv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0//v7+/////////////////94 + eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////oKCg/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+Wlpb///////////// + ///////////////Q0ND/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+ZmZn/////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zk5Of/29vb///////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////RERE/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/80NDT/+/v7/////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+U + lJT///////////////////////////92dnb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2xsbP///////////////////////////6enp/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5mZmf// + ///////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP/7+/v///////////// + ////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ/////////////////9zc3P8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/85OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/R0dH/2pqav9ra2v/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/a2tr//z8/P////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/89 + PT3/aGho/21tbf9tbW3/bW1t/21tbf9tbW3/bW1t/21tbf9tbW3/bW1t/21tbf9paWn/Pj4+/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zY2Nv9gYGD/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/+1 + tbX/////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/2pqav/39/f///////////////////////////////////////////////////////////// + /////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/SEhI/+bm5v////////////////////////////////// + ///////////////////////////////q6ur/TU1N/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/82Njb/x8fH//////// + ///////////////////////////////////////////////////////////////c3Nz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//b29v///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/xcXF//////////////////////// + ////////////////////////////////////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5SUlP///////////////////////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+T + k5P///////////////////////////////////////////////////////////////////////////+a + mpr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi////////////////////////////nZ2d/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2FhYf////////////////////////////////////////////////// + /////////////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + /////////////////////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM//R0dH///////////////////////////////////////////////////////////// + //////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////////////////////// + ////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////////////////////// + /////////////////////////////////////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9i + YmL///////////////////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////// + ////////////////////////////////////////////////////////////////////3Nzc/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9HR0f////////////////// + /////////////////////////////////////////////////////////3h4eP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+UlJT///////////////////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/n5+f//////////////////////////////////////////////////////////////////////// + ////pqam/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv///////////////////////////52dnf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3///////////////////////////////////////////// + ///////////////////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////86Ojr/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/0dHR//////////////////////////////////////////////////////// + ////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////// + /////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+fn5////////////////////////////// + //////////////////////////////////////////////+mpqb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/YmJi////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/21tbf// + /////////////////////////////////////////////////////////////////////////9zc3P8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + /////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R0dH///////////// + //////////////////////////////////////////////////////////////94eHj/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5+fn/////////////////////////////////////////////////////////////////// + /////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+d + nZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////////////////////////////////////// + ////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/2 + 9vb///////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ////////////////////////////////////////////////////////////////////Ojo6/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/9HR0f////////////////////////////////////////////////// + /////////////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////// + //////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/n5+f//////////////////////// + ////////////////////////////////////////////////////pqam/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/2JiYv///////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9t + bW3////////////////////////////////////////////////////////////////////////////c + 3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/0dHR//////// + ////////////////////////////////////////////////////////////////////eHh4/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+fn5////////////////////////////////////////////////////////////// + //////////////+mpqb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////////////////////// + ////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/21tbf////////////////////////////////// + /////////////////////////////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85 + OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v//////////////////////////////////////////////////////////////////////zo6Ov8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R0dH///////////////////////////////////////////// + //////////////////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////// + ////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////// + /////////////////////////////////////////////////////////6ampv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/bW1t//////////////////////////////////////////////////////////////////////// + ////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9HR0f// + /////////////////////////////////////////////////////////////////////////3h4eP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9sbGz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/n5+f//////////////////////////////////////////////////////// + ////////////////////pqam/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv////////////////// + /////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3///////////////////////////// + ///////////////////////////////////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/OTk5//b29v///////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/0dHR//////////////////////////////////////// + ////////////////////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP// + /////////////////////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+fn5////////////// + //////////////////////////////////////////////////////////////+mpqb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/YmJi////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/21tbf////////////////////////////////////////////////////////////////// + /////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R + 0dH///////////////////////////////////////////////////////////////////////////94 + eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////bGxs/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////////////////////////////////////// + /////////////////////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////// + //////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////////////////////// + ////////////////////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zk5Of/29vb///////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9DQ0P////////////////////////////////// + /////////////////////////////////////////3Z2dv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+U + lJT///////////////////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/np6e//////// + ////////////////////////////////////////////////////////////////////paWl/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2JiYv///////////////////////////52dnf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + ///////////////b29v/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/p6en///////////////////////////////////////////////////////////////////////8 + /Pz/UVFR/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/91dXX///////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/0lJSf/5+fn///////////// + /////////////////////////////////////////////////////////7Kysv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/85OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/89PT3/qamp/9PT0//U1NT/1NTU/9TU1P/U + 1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/y8vL/3R0dP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP+N + jY3/0NDQ/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/R0dH/k5OT/zU1Nf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/21tbf/Jycn/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U + 1NT/1NTU/9PT0/+urq7/QEBA/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//b29v///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////88PDz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5aWlv///////////////////////////25ubv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ZGRk////////////////////////////n5+f/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr/+Pj4//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + /////////////////////////////////////////////////////////1NTU/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////////////////////// + ////hYWF/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/97 + e3v///////////////////////////+2trb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0pKSv////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ////////////////////kJCQ/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zY2Nv/n5+f////////////////////////////CwsL/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/7m5uf///////////////////////////+3t7f86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/h4eH//////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + ///////////////////////////////////////////////////////////////s7Oz/RUVF/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/jIyM//////////////////////// + //////////39/f9lZWX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9e + Xl7/+/v7/////////////////////////////////5WVlf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0FBQf/n5+f///////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ///////////////////////////////S0tL/RERE/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/3d3d//5+fn//////////////////////////////////////+3t7f9cXFz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/V1dX/+np6f////////////////////////////////// + /////Pz8/39/f/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9CQkL/zMzM//////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ///////////////////////////////////////////////////////////////////////////////s + 7Oz/kJCQ/1JSUv89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/QkJC/2lpaf+6urr//v7+//////////////////////// + //////////////////////////n5+f+mpqb/Xl5e/z8/P/89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf8/Pz//XFxc/6Kiov/3 + 9/f//////////////////////////////////////////////////v7+/7+/v/9ra2v/Q0ND/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf9RUVH/jY2N/+np6f////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////39/f/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/+ + /v7///////////////////////////////////////////////////////////////////////////// + /////v7+//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//7+/v////////////////////////////////////////////////// + /////////////////////////////////////Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//f39//////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ///////////////////////////////IyMj/gICA/3Z2dv+lpaX/+Pj4//////////////////////// + /////////////////////////////////////////8rKyv+BgYH/dnZ2/6Ghof/19fX///////////// + ///////////////////////////////+/v7/4eHh/66urv+MjIz/eXl5/3Jycv94eHj/i4uL/66urv/k + 5OT///////////////////////////////////////////////////////////////////////z8/P/p + 6en/2NjY/9jY2P/k5OT/+Pj4////////////////////////////3t7e/4iIiP92dnb/pKSk//j4+P// + ///////////////////////////////////////////////////////////////////////////////x + 8fH/vLy8/5OTk/97e3v/c3Nz/3h4eP+Li4v/sLCw/+Xl5f////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ////////////////////////////////////////////////////////////////////t7e3/zU1Nf8z + MzP/MzMz/zMzM/9qamr//v7+//////////////////////////////////////////////////////+n + p6f/NTU1/zMzM/8zMzP/MzMz/15eXv/6+vr/////////////////////////////////wcHB/19fX/80 + NDT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zU1Nf9lZWX/yMjI//////////////////////// + //////////////////////////v7+/+dnZ3/SUlJ/zMzM/8zMzP/MzMz/zMzM/88PDz/b29v/9nZ2f// + /////////+Li4v8/Pz//MzMz/zMzM/8zMzP/cnJy//7+/v////////////////////////////////// + ///////////////////////////////o6Oj/hISE/zs7O/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NTU1/2lpaf/Pz8////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////9VVVX/MzMz/zMzM/8zMzP/MzMz/zMzM//Q0ND///////////// + ////////////////////////////////////0dHR/zc3N/8zMzP/MzMz/zMzM/8zMzP/MzMz/8fHx/// + ////////////////////9/f3/3t7e/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/80NDT/hoaG//r6+v//////////////////////////////////////hISE/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/RERE//f39///////kZGR/zMzM/8zMzP/MzMz/zMzM/8z + MzP/3d3d////////////////////////////////////////////////////////////vLy8/z8/P/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP+Pj4///Pz8//////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v/////////////////////////////////////////////////////////////////+/v7/zc3N/8z + MzP/MzMz/zMzM/8zMzP/MzMz/6+vr/////////////////////////////////////////////j4+P9U + VFT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/19fX//////////////////r6+v9sbGz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/f39///////// + /////////////////////////+bm5v82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/83 + Nzf/8PDw//////91dXX/MzMz/zMzM/8zMzP/MzMz/zMzM//CwsL///////////////////////////// + /////////////////////////7W1tf82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9/f3///v7+//////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ///////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////// + ////////////////////////////////////lJSU/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1hYWP/9 + /f3/////////////////lJSU/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/0xMTP+Hh4f/mZmZ/4eHh/9Q + UFD/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/yMjI////////////////////////////ubm5/zMzM/8z + MzP/MzMz/zMzM/82Njb/iIiI/5ubm/9/f3//ampq/7W1tf///////////3Nzc/8zMzP/MzMz/zMzM/8z + MzP/MzMz/7+/v//////////////////////////////////////////////////a2tr/OTk5/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0RERP9aWlr/S0tL/zQ0NP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+o + qKj//////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////j4+P82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz//////////////////////////////////////9jY2P84 + ODj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/t7e3/////////////////+bm5v85OTn/MzMz/zMzM/8z + MzP/MzMz/zMzM/+Ojo7/+fn5//////////////////z8/P+urq7/Pj4+/zMzM/8zMzP/MzMz/zMzM/+K + ior///////////////////////////+hoaH/MzMz/zMzM/8zMzP/MzMz/3d3d/////////////////// + ////////////////////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/v7+///////////////////////// + /////////////////////v7+/2lpaf8zMzP/MzMz/zMzM/8zMzP/MzMz/0JCQv+9vb3/+/v7///////+ + /v7/1tbW/1lZWf8zMzP/MzMz/zMzM/8zMzP/MzMz/0BAQP/w8PD///////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + ////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6ysrP// + ///////////////////////////////7+/v/XV1d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/15eXv/9 + /f3/////////////////l5eX/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//7+/v////////////////// + ///////////////d3d3/UFBQ/zMzM/8zMzP/MzMz/6Ojo////////////////////////////5eXl/8z + MzP/MzMz/zMzM/8zMzP/lZWV//////////////////////////////////////9zc3P/MzMz/zMzM/8z + MzP/MzMz/zMzM/+/v7/////////////////////////////////////////////b29v/NDQ0/zMzM/8z + MzP/MzMz/zMzM/82Njb/z8/P////////////////////////////8PDw/0tLS/8zMzP/MzMz/zMzM/8z + MzP/MzMz/6Wlpf////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra//////////////////////////////////////////////////////////////////4 + +Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys/////////////////////////////////6CgoP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/84ODj/1tbW//////////////////////9cXFz/MzMz/zMzM/8z + MzP/MzMz/zMzM//Kysr////////////////////////////////////////////u7u7/hISE/2dnZ/+X + l5f/+vr6////////////////////////////lZWV/zMzM/8zMzP/MzMz/zMzM/+ampr///////////// + /////////////////////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/7+/v/////////////////// + /////////////////////////5ycnP8zMzP/MzMz/zMzM/8zMzP/MzMz/3Nzc/////////////////// + ////////////////////q6ur/zMzM/8zMzP/MzMz/zMzM/8zMzP/ZmZm//////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+s + rKz////////////////////////////a2tr/Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5qamv// + ////////////////////+fn5/zw8PP8zMzP/MzMz/zMzM/8zMzP/NDQ0/7S0tP++vr7/vr6+/76+vv++ + vr7/vr6+/76+vv++vr7/vr6+/7+/v//Ly8v/6urq//////////////////////////////////////+V + lZX/MzMz/zMzM/8zMzP/MzMz/5qamv//////////////////////////////////////c3Nz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/vr6+////////////////////////////////////////////dHR0/zMzM/8z + MzP/MzMz/zMzM/8zMzP/sbGx///////////////////////////////////////p6en/MzMz/zMzM/8z + MzP/MzMz/zMzM/9AQED//Pz8////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + ////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6ysrP//////////////////////7+/v/1JSUv8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3//f39///////////////////////u7u7/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/81 + NTX/ZmZm/+rq6v///////////////////////////5WVlf8zMzP/MzMz/zMzM/8zMzP/mpqa//////// + //////////////////////////////9zc3P/MzMz/zMzM/8zMzP/MzMz/zMzM/+4uLj///////////// + //////////////////////////////9eXl7/MzMz/zMzM/8zMzP/MzMz/zMzM//Pz8////////////// + //////////////////////////////87Ozv/MzMz/zMzM/8zMzP/MzMz/zY2Nv/w8PD///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ///////////////////////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8z + MzP/rKys/////////////////+Dg4P9YWFj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/W1tb//Pz8/// + /////////////////////////+np6f8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/fHx8//////////////////////// + ////lZWV/zMzM/8zMzP/MzMz/zMzM/+ampr//////////////////////////////////////3Nzc/8z + MzP/MzMz/zMzM/8zMzP/MzMz/62trf///////////////////////////////////////////1lZWf8z + MzP/MzMz/zMzM/8zMzP/MzMz/9bW1v///////////////////////////////////////////0FBQf8z + MzP/MzMz/zMzM/8zMzP/NDQ0/+zs7P///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/9mZmb/iYmJ/4KCgv9lZWX/NjY2/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2JiYv/v7+//////////////////////////////////8/Pz/zQ0NP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9NTU3///////////////////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv// + ////////////////////////////////////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ//////// + ////////////////////////////////////ZGRk/zMzM/8zMzP/MzMz/zMzM/8zMzP/x8fH//////// + ///////////////////////////////7+/v/NjY2/zMzM/8zMzP/MzMz/zMzM/84ODj/9PT0//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + ////////////////////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/83Nzf/mpqa/+np6f// + ///////////////////////////////9/f3/RkZG/zMzM/8zMzP/MzMz/zMzM/8zMzP/m5ub/6urq/+r + q6v/q6ur/6urq/+rq6v/q6ur/6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/01NTf////////////////// + /////////5WVlf8zMzP/MzMz/zMzM/8zMzP/mpqa//////////////////////////////////////9z + c3P/MzMz/zMzM/8zMzP/MzMz/zMzM/92dnb///////////////////////////////////////////+A + gID/MzMz/zMzM/8zMzP/MzMz/zMzM/+enp7//////////////////////////////////////9fX1/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0pKSv////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ///////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5/4qKiv/09PT///////////////////////////9y + cnL/MzMz/zMzM/8zMzP/MzMz/zMzM/+zs7P/////////////////////////////////ysrK/zMzM/8z + MzP/MzMz/zMzM/8zMzP/aWlp////////////////////////////lZWV/zMzM/8zMzP/MzMz/zMzM/+a + mpr//////////////////////////////////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/0NDQ//5 + +fn//////////////////////////////////////7CwsP8zMzP/MzMz/zMzM/8zMzP/MzMz/1VVVf/8 + /Pz/////////////////////////////////ioqK/zMzM/8zMzP/MzMz/zMzM/8zMzP/enp6//////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////j4+P82Njb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/1dXV//r6+v//////////////////////7a2tv8zMzP/MzMz/zMzM/8zMzP/MzMz/1FRUf/z + 8/P///////////////////////v7+/9mZmb/MzMz/zMzM/8zMzP/MzMz/zMzM/+jo6P///////////// + //////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv////////////////////////////////// + ////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5ycnP/////////////////z8/P/0dHR/+7u7v// + ////7+/v/zs7O/8zMzP/MzMz/zMzM/8zMzP/MzMz/5iYmP/+/v7//////////////////////8fHx/84 + ODj/MzMz/zMzM/8zMzP/MzMz/zMzM//BwcH///////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2VlZf/+/v7///////////// + ////+fn5/05OTv8zMzP/MzMz/zMzM/8zMzP/MzMz/1lZWf/FxcX/8vLy//Pz8//Ozs7/aGho/zMzM/8z + MzP/MzMz/zMzM/8zMzP/Pz8//+/v7////////////////////////////5WVlf8zMzP/MzMz/zMzM/8z + MzP/mpqa//////////////////////////////////////9zc3P/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NDQ0/2RkZP99fX3/YmJi/zk5Of8zMzP/PDw8/7y8vP//////jIyM/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/3Jycv/CwsL/29vb/8zMzP+NjY3/ODg4/zMzM/8zMzP/MzMz/zMzM/8zMzP/W1tb//39/f// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ///////////////////////////////////////////////////////////////4+Pj/NjY2/zMzM/8z + MzP/MzMz/zMzM/8zMzP/pKSk//Ly8v/y8vL/8vLy//Ly8v/u7u7/4eHh/8XFxf+RkZH/Pz8//zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/8LCwv//////////////////////xMTE/zU1Nf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/82Njb/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+oqKj///////////// + ////9vb2/3Nzc/88PDz/NjY2/zMzM/8zMzP/MzMz/zMzM/82Njb/OTk5/0ZGRv92dnb/7e3t//////// + /////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/RUVF//v7+//z8/P/UFBQ/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zs7O//W1tb///////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz///////////// + ///////////////////////////////c3Nz/Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/f39///////// + ////////////////////paWl/zQ0NP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/hoaG//7+/v////////////////+2trb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+goKD/////////////////fn5+/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/+Pj4///////f39//SUlJ/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr/v7+///////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v/////////////////////////////////////////////////////////////////+Pj4/zY2Nv8z + MzP/MzMz/zMzM/8zMzP/MzMz/6ysrP////////////////////////////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/9cXFz/////////////////////////////////vLy8/0ZGRv8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Ozs7/6SkpP/+/v7///////////// + /////////9zc3P88PDz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NjY2/8nJyf// + //////////////+tra3/MzMz/zMzM/8zMzP/MzMz/0pKSv9dXV3/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/4eHh//////////////////q6ur/bW1t/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/VlZW/9TU1P////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ///////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////// + /////////////////////////////////////////3BwcP8zMzP/MzMz/zMzM/8zMzP/MzMz/1NTU/// + ////////////////////////////////////8vLy/6Kiov9aWlr/NTU1/zMzM/8zMzP/MzMz/zMzM/80 + NDT/SUlJ/46Ojv/m5ub//////////////////////////////////////+fn5/+4uLj/cHBw/zMzM/8z + MzP/MzMz/zMzM/9zc3P/srKy/7S0tP/d3d3///////////////////////j4+P9kZGT/MzMz/zMzM/84 + ODj/urq6//Dw8P9ycnL/NDQ0/zMzM/8zMzP/MzMz/01NTf+oqKj//Pz8///////////////////////+ + /v7/ysrK/3d3d/8+Pj7/MzMz/zMzM/8zMzP/MzMz/zMzM/84ODj/Z2dn/7W1tf/6+vr///////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////j4+P82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz////////////////////////////////////////////r + 6+v/Pz8//zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////////////////////////////////////// + ///////////////w8PD/0dHR/8HBwf++vr7/ysrK/+Xl5f/+/v7///////////////////////////// + //////////////////////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv////////////////// + //////////////////////////z8/P/Pz8//wMDA/+jo6P/////////////////k5OT/wsLC/8HBwf/d + 3d3//v7+//////////////////////////////////////////////////v7+//d3d3/xsbG/729vf/D + w8P/19fX//b29v////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + ////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6urq//9 + /f3//f39//39/f/9/f3//f39//v7+//z8/P/xMTE/1VVVf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+N + jY3///////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////5WVlf8z + MzP/MzMz/zMzM/8zMzP/mpqa//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra//////////////////////////////////////////////////////////////////4 + +Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/Pj4+/0VFRf9FRUX/RUVF/0VFRf9ERET/Pj4+/zQ0NP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0/9jY2P////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////lpaW/zMzM/8zMzP/MzMz/zMzM/+ampr///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////r6+v82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+C + goL///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////+c + nJz/MzMz/zMzM/8zMzP/MzMz/6Kiov////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////0tLS/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ampq//f39/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/zMzM/8zMzP/xMTE//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////oaGh/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/PT09/5iYmP/7 + +/v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////f39/4aGhv85OTn/Ozs7/4+Pj//+/v7///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + ///////////////9/f3/sLCw/2dnZ/9PT0//TU1N/01NTf9NTU3/TU1N/01NTf9NTU3/TU1N/01NTf9N + TU3/T09P/1ZWVv9lZWX/gICA/7CwsP/w8PD///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////X19f/29vb///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/Ozs7/zMzM/9fX1////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////9wcHD/MzMz/zMzM/89PT3cMzMz/z4+Pv/39/f///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////Pz8/0pKSv8z + MzP/OTk57Dw8PJUzMzP/MzMz/7W1tf////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////Gxsb/MzMz/zMzM/87OzuqS0tLNjQ0NP0zMzP/TExM//Hx8f// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////9/f3/1dXV/8z + MzP/MzMz/0VFRUmNjY0APDw8rTMzM/8zMzP/ZGRk//Hx8f////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////b29v9wcHD/MzMz/zMzM/86OjrAgYGBAgAAAABTU1MYNzc34zMzM/8z + MzP/TExM/7a2tv/39/f///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////r6+v++vr7/U1NT/zMzM/8z + MzP/NjY27E1NTSMAAAAAAAAAAAAAAABKSkoqNzc34jMzM/8zMzP/MzMz/z4+Pv9gYGD/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9iYmL/QUFB/zMzM/8zMzP/MzMz/zY2NutHR0c3AAAAAAAAAAAAAAAAAAAAAAAAAABT + U1MXPDw8rDQ0NP0zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP47 + Ozu5TU1NIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACNjY0AS0tLNTw8PJU9PT3bOzs7/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/Ojo6/0BAQOM8PDycR0dHPo+PjwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////////////////// + ////////////////////+AAAAAAAAAAAAAAAAAAAH+AAAAAAAAAAAAAAAAAAAAfAAAAAAAAAAAAAAAAA + AAADgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAHA + AAAAAAAAAAAAAAAAAAAD4AAAAAAAAAAAAAAAAAAAB/AAAAAAAAAAAAAAAAAAAA////////////////// + //////////////////////////////////////////////8oAAAAQAAAAIAAAAABACAAAAAAAABAAAAT + CwAAEwsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgICAAENDQyYzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQEJCQil0dHQAAAAAAAAAAAAAAAAAPz8/JTc3N8Iz + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Nzc3yD8/PyoA + AAAAQkJCGzU1NedWVlb/xMTE/+/v7//y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/v + 7+//x8fH/1paWv81NTXsQUFBIDg4OJxKSkr/8PDw//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////09PT/T09P/zc3N6Y4ODjum5ub//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////6Ojo/81NTX0MzMz/7W1tf// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////+9 + vb3/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////++vr7/MzMz/zMzM/+1 + tbX/////////////////////////////////29vb/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a + 2tr/2tra/9ra2v/a2tr/5+fn//////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////k5OT/2tra/9ra2v/a + 2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/g4OD//f39//////////////////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9wcHD/9vb2//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////aGho/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1RUVP/h + 4eH//////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////83 + Nzf/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5aWlv// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////2hoaP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/ZWVl//////////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ////////////////////Nzc3/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9mZmb///////////////////////////////////////////////////////////// + //////////////////////////////////////////////9oaGj/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zg4OP/8/Pz/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////////66urv+tra3/ra2t/62trf+tra3/ra2t/62trf+t + ra3/ra2t/62trf+RkZH/NDQ0/zMzM/8zMzP/Y2Nj//////////////////////////////////////// + ////////////////////////////////////////////////////////////////////wsLC/62trf+t + ra3/ra2t/62trf+tra3/ra2t/62trf+tra3/ra2t/6Ojo/9AQED/MzMz/zMzM/82Njb/+/v7//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////////////////////// + /////////////////////////////////////////1FRUf8zMzP/MzMz/2NjY/////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////hISE/zMzM/8z + MzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////// + //////////////////////////////////////////////////////////////9VVVX/MzMz/zMzM/9j + Y2P///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////4iIiP8zMzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8zMzP/tbW1//////// + //////////////////////////7+/v+ysrL/cnJy/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr//39/f// + ////VVVV/zMzM/8zMzP/Y2Nj/////////////////+/v7/+Pj4//bGxs/2tra/9ra2v/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/bGxs/46Ojv/t7e3//////////////////////87Ozv96enr/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/2tra//////+IiIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////////+enp7/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM//9/f3//////1VVVf8zMzP/MzMz/2NjY/////////////r6+v9WVlb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/U1NT//j4+P///////////8zMzP82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/8zMzP//////iIiI/zMzM/8zMzP/NjY2//v7+/// + //////////////++vr7/MzMz/zMzM/+1tbX/////////////////////////////////RkZG/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP//f39//////9VVVX/MzMz/zMzM/9jY2P////////////F + xcX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//A + wMD///////////93d3f/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//MzMz//////4iIiP8z + MzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + /////////zc3N/8zMzP/MzMz/zMzM/9GRkb/T09P/09PT/9PT0//T09P//39/f//////VVVV/zMzM/8z + MzP/Y2Nj////////////tra2/zMzM/8zMzP/MzMz/zU1Nf9PT0//UFBQ/1BQUP9QUFD/UFBQ/09PT/82 + Njb/MzMz/zMzM/8zMzP/sbGx////////////aGho/zMzM/8zMzP/MzMz/z8/P/9PT0//T09P/09PT/9P + T0//09PT//////+IiIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////83Nzf/MzMz/zMzM/9lZWX//f39//////////////////////// + /////////1VVVf8zMzP/MzMz/2NjY////////////7a2tv8zMzP/MzMz/zMzM/+wsLD///////////// + ////////////////////tLS0/zMzM/8zMzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/z8/P//x + 8fH/////////////////////////////////iIiI/zMzM/8zMzP/NjY2//v7+/////////////////++ + vr7/MzMz/zMzM/+1tbX/////////////////////////////////Nzc3/zMzM/8zMzP/goKC//////// + //////////////////////////////9VVVX/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8z + MzP/z8/P/////////////////////////////////9LS0v8zMzP/MzMz/zMzM/+xsbH///////////9o + aGj/MzMz/zMzM/9QUFD//////////////////////////////////////4iIiP8zMzP/MzMz/zY2Nv/7 + +/v/////////////////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8z + MzP/MzMz/4KCgv//////////////////////////////////////VVVV/zMzM/8zMzP/Y2Nj//////// + ////tra2/zMzM/8zMzP/MzMz/8/Pz//////////////////////////////////S0tL/MzMz/zMzM/8z + MzP/sbGx////////////aGho/zMzM/8zMzP/UFBQ//////////////////////////////////////+I + iIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////83Nzf/MzMz/zMzM/+CgoL//////////////////////////////////////1VVVf8z + MzP/MzMz/2NjY////////////7a2tv8zMzP/MzMz/zMzM//Pz8////////////////////////////// + ////0tLS/zMzM/8zMzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/1BQUP////////////////// + ////////////////////iIiI/zMzM/8zMzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1 + tbX/////////////////////////////////Nzc3/zMzM/8zMzP/goKC//////////////////////// + //////////////9VVVX/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8zMzP/z8/P//////// + /////////////////////////9LS0v8zMzP/MzMz/zMzM/+xsbH///////////9oaGj/MzMz/zMzM/9Q + UFD//////////////////////////////////////4iIiP8zMzP/MzMz/zY2Nv/7+/v///////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8zMzP/MzMz/4KCgv// + ////////////////////////////////////VVVV/zMzM/8zMzP/Y2Nj////////////tra2/zMzM/8z + MzP/MzMz/8/Pz//////////////////////////////////S0tL/MzMz/zMzM/8zMzP/sbGx//////// + ////aGho/zMzM/8zMzP/UFBQ//////////////////////////////////////+IiIj/MzMz/zMzM/82 + Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////83 + Nzf/MzMz/zMzM/93d3f//////////////////////////////////v7+/0tLS/8zMzP/MzMz/2NjY/// + /////////7a2tv8zMzP/MzMz/zMzM//ExMT/////////////////////////////////yMjI/zMzM/8z + MzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/0dHR//9/f3///////////////////////////// + ////fX19/zMzM/8zMzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ////////////////////Nzc3/zMzM/8zMzP/NjY2/3h4eP+EhIT/hISE/4SEhP+EhIT/hISE/2lpaf8z + MzP/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8zMzP/SkpK/4KCgv+EhIT/hISE/4SEhP+E + hIT/g4OD/0xMTP8zMzP/MzMz/zMzM/+xsbH///////////9oaGj/MzMz/zMzM/8zMzP/Z2dn/4SEhP+E + hIT/hISE/4SEhP+EhIT/enp6/zY2Nv8zMzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////////z09Pf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ampq////////////vLy8/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/t7e3////////////b29v/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr//f39//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////99fX3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/6qqqv///////////+/v7/9A + QED/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Pj4+/+zs7P// + /////////6+vr/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/eHh4//////////////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////// + ////9PT0/319ff89PT3/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/RERE/5iYmP/+ + /v7/////////////////0NDQ/1paWv84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84 + ODj/WVlZ/83Nzf/////////////////+/v7/nJyc/0VFRf84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84 + ODj/ODg4/zg4OP89PT3/e3t7//Ly8v//////////////////////vr6+/zMzM/8zMzP/tbW1//////// + //////////////////////////////////////////7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+/////////////////////////////////////////////v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+/////////////////////////////////////////////v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//////////////////////////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////++ + vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////Hx8f+9 + vb3/5+fn/////////////////////////////////9LS0v/FxcX//Pz8///////////////////////j + 4+P/wcHB/7q6uv/Ozs7/+Pj4//////////////////////////////////n5+f/r6+v/9/f3//////// + ////9/f3/7+/v//n5+f///////////////////////////////////////z8/P/T09P/u7u7/8DAwP/l + 5eX//////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////9dXV3/MzMz/0FBQf/z8/P//////////////////////6urq/8zMzP/MzMz/5SUlP// + /////////9zc3P9iYmL/MzMz/zMzM/8zMzP/MzMz/0BAQP+goKD//v7+/////////////////5SUlP85 + OTn/MzMz/zU1Nf9wcHD//f39/3l5ef8zMzP/Q0ND//b29v///////////////////////////7m5uf9J + SUn/MzMz/zMzM/8zMzP/MzMz/2hoaP/i4uL///////////////////////////++vr7/MzMz/zMzM/+1 + tbX////////////////////////////8/Pz/NTU1/zMzM/8zMzP/1tbW/////////////////+Li4v87 + Ozv/MzMz/zMzM/+YmJj//////+Pj4/9BQUH/MzMz/zMzM/9OTk7/YWFh/zo6Ov8zMzP/MzMz/56env// + /////////+fn5/80NDT/MzMz/0lJSf9gYGD/YmJi//v7+/9UVFT/MzMz/zMzM//g4OD///////////// + /////////7Kysv80NDT/MzMz/zMzM/9BQUH/OTk5/zMzM/8zMzP/RkZG/+np6f////////////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////Pz8/zQ0NP8zMzP/MzMz/9XV1f// + //////////7+/v9oaGj/MzMz/zMzM/8+Pj7/7e3t//////96enr/MzMz/zMzM/+Li4v//v7+///////q + 6ur/aGho/zMzM/9lZWX////////////Ozs7/MzMz/zMzM//Dw8P/////////////////U1NT/zMzM/8z + MzP/39/f//////////////////b29v9BQUH/MzMz/zQ0NP+zs7P//v7+//T09P9ycnL/MzMz/zMzM/+C + goL//////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////z8/P80 + NDT/MzMz/zMzM//V1dX///////////+tra3/MzMz/zMzM/8zMzP/qqqq///////+/v7/QEBA/zMzM/8z + MzP/z8/P/97e3v/e3t7/3t7e/9ra2v+oqKj/5OTk////////////ysrK/zMzM/8zMzP/zMzM//////// + /////////1NTU/8zMzP/MzMz/9/f3//////////////////Dw8P/MzMz/zMzM/9iYmL///////////// + ////5OTk/zMzM/8zMzP/Q0ND//7+/v////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ///////////////8/Pz/NDQ0/zMzM/8zMzP/1dXV///////Jycn/Ozs7/zMzM/8zMzP/e3t7//7+/v// + ////9fX1/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/4CAgP///////////8rKyv8z + MzP/MzMz/8zMzP////////////////9TU1P/MzMz/zMzM//Z2dn/////////////////ra2t/zMzM/8z + MzP/g4OD//////////////////////84ODj/MzMz/zQ0NP/39/f/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////Pz8/zQ0NP8zMzP/MzMz/1VVVf9TU1P/NDQ0/zMzM/8z + MzP/WVlZ//b29v////////////v7+/84ODj/MzMz/zMzM/9ra2v/b29v/29vb/9ubm7/MzMz/zMzM/9A + QED////////////Kysr/MzMz/zMzM//MzMz/////////////////U1NT/zMzM/8zMzP/w8PD//////// + /////////7i4uP8zMzP/MzMz/3Nzc//////////////////09PT/NDQ0/zMzM/86Ojr//Pz8//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////z8/P80NDT/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9KSkr/zc3N////////////ZGRk/zMzM/8zMzP/vb29//////// + ////ysrK/zMzM/8zMzP/XV1d////////////ysrK/zMzM/8zMzP/zMzM/////////////////1NTU/8z + MzP/MzMz/4ODg////////Pz8/+/v7//n5+f/NTU1/zMzM/88PDz/5OTk////////////oqKi/zMzM/8z + MzP/aGho//////////////////////++vr7/MzMz/zMzM/+1tbX////////////////////////////8 + /Pz/NDQ0/zMzM/8zMzP/f39//5KSkv+SkpL/g4OD/01NTf8zMzP/MzMz/z8/P//v7+///////8PDw/80 + NDT/MzMz/zw8PP+IiIj/i4uL/0BAQP8zMzP/MzMz/7W1tf//////2tra/4GBgf8zMzP/MzMz/4KCgv+u + rq7/+vr6//////9TU1P/MzMz/zMzM/8zMzP/UlJS/0BAQP81NTX/v7+//4GBgf8zMzP/MzMz/0NDQ/+B + gYH/cHBw/zQ0NP8zMzP/NTU1/8vLy///////////////////////vr6+/zMzM/8zMzP/tbW1//////// + /////////////////////Pz8/zQ0NP8zMzP/MzMz/9XV1f/////////////////29vb/Q0ND/zMzM/8z + MzP/tra2////////////paWl/zg4OP8zMzP/MzMz/zMzM/8zMzP/NTU1/5eXl////////////4CAgP8z + MzP/MzMz/zMzM/8zMzP/NDQ0/9ra2v//////ZGRk/zMzM/85OTn/Pj4+/zMzM/8zMzP/MzMz/66urv/3 + 9/f/dXV1/zMzM/8zMzP/MzMz/zMzM/8zMzP/PDw8/7Ozs////////////////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////z8/P80NDT/MzMz/zMzM//V1dX///////////// + ////+vr6/0VFRf8zMzP/MzMz/62trf/////////////////l5eX/n5+f/35+fv97e3v/mJiY/9zc3P// + ///////////////5+fn/r6+v/zMzM/8zMzP/sLCw/+Tk5P///////////9bW1v99fX3/tra2/9jY2P+D + g4P/gYGB/7y8vP/+/v7////////////Q0ND/k5OT/3p6ev+AgID/paWl/+vr6/////////////////// + //////////////++vr7/MzMz/zMzM/+1tbX////////////////////////////8/Pz/NDQ0/zMzM/8z + MzP/i4uL/6Ghof+hoaH/mJiY/2BgYP8zMzP/MzMz/zMzM//Z2dn///////////////////////////// + /////////////////////////////////////////8rKyv8zMzP/MzMz/83Nzf////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + /////v7+/zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+FhYX///////////// + ///////////////////////////////////////////////////////////////W1tb/MzMz/zMzM//Z + 2dn///////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////+goKD/R0dH/0BAQP9AQED/QEBA/0BAQP9AQED/SEhI/2VlZf+x + sbH//v7+//////////////////////////////////////////////////////////////////////// + /////v7+/62trf+wsLD///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////++ + vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////++vr7/MzMz/zMzM/+1 + tbX///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////+9vb3/MzMz/zc3N/alpaX///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////ra2t/zQ0NPo3 + NzeyWlpa//v7+/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////f39/2FhYf83Nze8Pj4+MTQ0NPh1dXX/6+vr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////7e3t/3t7e/80NDT6Pj4+OQAAAAA8PDxJNTU16jY2Nv9MTEz/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/TU1N/zY2Nv81NTXtOzs7UAAAAAAAAAAAAAAAAEtLSw09 + PT1cNzc3gDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDc3N4A+Pj5gSEhIEAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAP//////////wAAAAAAAAAOAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAHAAAAAAAAAA///////////KAAAADAAAABg + AAAAAQAgAAAAAAAAJAAAEwsAABMLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9PT0lNzc3kTMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzY2NpQ8PDwoAAAAADs7OzM6 + OjrvkpKS/8DAwP/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/C + wsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/C + wsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wcHB/5WVlf87 + OzvxOzs7ODY2NsGtra3///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////+0tLT/NjY2yTs7O/zv7+////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////z8/P/Ozs7/jo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////01NTf9KSkr/SkpK/0pKSv9KSkr/SkpK/0pKSv9K + Skr/SkpK/11dXf/MzMz///////////////////////////////////////////////////////////// + ////////////////////m5ub/0pKSv9KSkr/SkpK/0pKSv9KSkr/SkpK/0pKSv9KSkr/Tk5O/5CQkP/8 + /Pz////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9ISEj/+vr6//////////////////////////////////////// + ////////////////////////////////////jo6O/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+2trb////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////15eXv9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9YWFj/NTU1/zMzM/80NDT/7+/v//////////////////////// + ////////////////////////////////////////////////////pKSk/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9DQ0P/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + ////////////////////////////////////////////////////dHR0/zMzM/80NDT/7+/v//////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////MzMz/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT////////////////////////////6+vr/8/Pz//Pz8//z8/P/8/Pz//b29v//////gICA/zMzM/80 + NDT/7+/v/////////////Pz8//T09P/z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//29vb///////////// + //////////7+/v/09PT/8/Pz//Pz8//z8/P/8/Pz//39/f/Z2dn/MzMz/zMzM/+YmJj////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////8HBwf9HR0f/NjY2/zY2Nv82Njb/NjY2/2dnZ/// + ////gICA/zMzM/80NDT/7+/v///////e3t7/VVVV/zY2Nv82Njb/NjY2/zY2Nv82Njb/NjY2/zY2Nv88 + PDz/kJCQ////////////8/Pz/2pqav84ODj/NjY2/zY2Nv82Njb/NjY2/9nZ2f/Z2dn/MzMz/zMzM/+Y + mJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////0pKSv8zMzP/MzMz/zMzM/8z + MzP/MzMz/2VlZf//////gICA/zMzM/80NDT/7+/v//////92dnb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/9jY2P//////oqKi/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9nZ2f/Z + 2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8z + MzP/NjY2/3V1df97e3v/e3t7/5ubm///////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/zMzM/9o + aGj/fHx8/3x8fP98fHz/e3t7/0ZGRv8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/WVlZ/3t7e/97 + e3v/e3t7/+bm5v/Z2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////////zY2Nv8zMzP/ampq////////////////////////////gICA/zMzM/80NDT/7+/v//////9i + YmL/MzMz/0FBQf/7+/v//////////////////////6ampv8zMzP/MzMz/8TExP//////jo6O/zMzM/8z + MzP/3d3d///////////////////////Z2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////////zY2Nv8zMzP/bm5u////////////////////////////gICA/zMzM/80 + NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7//////////////////////6urq/8zMzP/MzMz/8TExP// + ////jo6O/zMzM/8zMzP/4uLi///////////////////////Z2dn/MzMz/zMzM/+YmJj////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/bm5u//////////////////////// + ////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7//////////////////////6urq/8z + MzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/4uLi///////////////////////Z2dn/MzMz/zMzM/+Y + mJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/bm5u//////// + ////////////////////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7///////////// + /////////6urq/8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/4uLi///////////////////////Z + 2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8z + MzP/aGho////////////////////////////enp6/zMzM/80NDT/7+/v//////9iYmL/MzMz/0BAQP/7 + +/v//////////////////////6Wlpf8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/29vb//////// + ///////////////S0tL/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////////zY2Nv8zMzP/NDQ0/2lpaf9vb2//b29v/29vb/9ra2v/Nzc3/zMzM/80NDT/7+/v//////9i + YmL/MzMz/zMzM/9dXV3/b29v/29vb/9vb2//b29v/0FBQf8zMzP/MzMz/8TExP//////jo6O/zMzM/8z + MzP/UFBQ/29vb/9vb2//b29v/29vb/9OTk7/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////////01NTf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9D + Q0P/+Pj4//////95eXn/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0/9vb2/// + ////paWl/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+wsLD////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////8nJyf9NTU3/Nzc3/zc3N/83Nzf/Nzc3/zc3N/83 + Nzf/Nzc3/0hISP+9vb3////////////j4+P/Xl5e/zc3N/83Nzf/Nzc3/zc3N/83Nzf/Nzc3/zc3N/8/ + Pz//m5ub////////////9vb2/3R0dP85OTn/Nzc3/zc3N/83Nzf/Nzc3/zc3N/83Nzf/OTk5/3t7e//4 + +Pj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////////////////v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+//////////////////////////////////7+/v/+/v7//v7+//7+/v/+ + /v7//v7+//7+/v/////////////////////////////////+/v7//v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+///////////////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT///////////////////////7+/v/6+vr////////////////////////////5 + +fn///////////////////////7+/v/39/f//Pz8//////////////////////////////////////// + //////////7+/v/6+vr//////////////////////////////////v7+//b29v/9/f3///////////// + ///////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////2xsbP9ERET/2dnZ//////// + /////////6ampv8+Pj7/lJSU///////6+vr/mJiY/01NTf88PDz/QUFB/3Z2dv/j4+P////////////g + 4OD/bm5u/1hYWP90dHT/6+vr/39/f/9ERET/3Nzc//////////////////v7+/+bm5v/TU1N/zs7O/9F + RUX/goKC/+/v7//////////////////4+Pj/PDw8/zo6Ov/09PT//////////////////f39/zQ0NP8z + MzP/ra2t////////////2tra/zg4OP8zMzP/ioqK//////9+fn7/MzMz/0ZGRv+Hh4f/ZmZm/zQ0NP9M + TEz/+Pj4//////+Dg4P/MzMz/29vb/+BgYH/5eXl/0tLS/8zMzP/tLS0/////////////////4GBgf8z + MzP/Pj4+/3BwcP9JSUn/MzMz/1lZWf/4+Pj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////Pz8/zQ0NP8zMzP/ra2t///////7+/v/YGBg/zMzM/9AQED/5+fn/+Xl5f82Njb/MzMz/9bW1v// + /////v7+/6mpqf9vb2//9/f3//////9ycnL/MzMz/9fX1////////////0tLS/8zMzP/tLS0//////// + ////5ubm/zQ0NP80NDT/y8vL///////s7Oz/QUFB/zMzM/+5ubn////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////Pz8/zQ0NP8zMzP/ra2t//39/f+Pj4//MzMz/zU1Nf+4uLj//////8fHx/8z + MzP/MzMz/01NTf9OTk7/Tk5O/09PT/9wcHD/9fX1//////9xcXH/MzMz/9nZ2f///////////0tLS/8z + MzP/sLCw////////////w8PD/zMzM/9AQED/+fn5////////////Z2dn/zMzM/+VlZX////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8zMzP/RkZG/0ZGRv8zMzP/MzMz/2pqav/x + 8fH//////9LS0v8zMzP/MzMz/4uLi/+Tk5P/jo6O/zMzM/8zMzP/2NjY//////9xcXH/MzMz/9nZ2f// + /////////0tLS/8zMzP/mZmZ////////////z8/P/zMzM/84ODj/7u7u///////+/v7/V1dX/zMzM/+h + oaH////////////4+Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8zMzP/QEBA/0lJSf9G + Rkb/NjY2/zMzM/9NTU3/6enp//j4+P9LS0v/MzMz/4yMjP/f39//lZWV/zMzM/9FRUX/9vb2//b29v9q + amr/MzMz/8bGxv/5+fn//////0tLS/8zMzP/S0tL/6+vr/+SkpL/z8/P/0lJSf8zMzP/enp6/9PT0/+b + m5v/NDQ0/zg4OP/e3t7////////////4+Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8z + MzP/rKys//39/f/8/Pz/4ODg/zw8PP8zMzP/nJyc///////Ly8v/QUFB/zMzM/8zMzP/MzMz/z09Pf/C + wsL//////3R0dP8zMzP/MzMz/zQ0NP+Ghob//////1VVVf8zMzP/PDw8/zMzM/8zMzP/jIyM/83Nzf9D + Q0P/MzMz/zMzM/8zMzP/ODg4/62trf/////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////Pz8/zQ0NP8zMzP/ra2t////////////6+vr/z09Pf8zMzP/kpKS////////////8PDw/7Kysv+b + m5v/ra2t/+zs7P////////////X19f9oaGj/MzMz/8LCwv/39/f//////9HR0f+oqKj/5eXl/6CgoP+z + s7P/+Pj4///////y8vL/tLS0/5ubm/+rq6v/5ubm///////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////f39/zU1Nf8zMzP/RkZG/1NTU/9RUVH/PT09/zMzM/83Nzf/0tLS//////// + //////////////////////////////////////////////90dHT/MzMz/9vb2/////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////3l5ef8+Pj7/PT09/z09Pf89PT3/QkJC/2FhYf/B + wcH////////////////////////////////////////////////////////////FxcX/i4uL//r6+v// + //////////////////////////////////////////////////////////////////////////////// + ///////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zs7O/7x8fH///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////19fX/Ozs7/zY2NtC/v7////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////FxcX/NjY21zo6OkpDQ0P5srKy/+Dg4P/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4ODg/7W1tf9FRUX7OTk5UHFxcQA6OjpCNjY2uTQ0NN8z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfNDQ03zc3N7w6OjpGa2trAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAP///////wAAgAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////8AACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAABMLAAAT + CwAAAAAAAAAAAAAAAAAARERECjMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAz + MzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAz + MzMgMzMzIDMzMyBDQ0MKAAAAADc3N0pjY2PwkpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+S + kpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+S + kpL/kpKS/5KSkv+SkpL/kpKS/2VlZfE3NzdOWVlZ4vv7+/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////Pz8/1tbW+Z0dHT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////eHh4/3R0dP////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////94eHj/dHR0//////// + /////////4iIiP+Hh4f/h4eH/4eHh/+Hh4f/h4eH/6Kiov/9/f3///////////////////////////// + ////////////////////0tLS/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/iIiI/8zMzP///////////3h4eP90 + dHT/////////////////NTU1/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/7+/v/////////////////// + //////////////////////////////+0tLT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/QUFB//7+/v// + ////eHh4/3R0dP/////////////////W1tb/1tbW/9bW1v/W1tb/1tbW/4WFhf8zMzP/sbGx//////// + //////////////////////////////////////////Dw8P/W1tb/1tbW/9bW1v/W1tb/09PT/0pKSv81 + NTX//f39//////94eHj/dHR0/////////////////+zs7P+3t7f/tbW1/7W1tf/a2tr/qqqq/zMzM/+x + sbH//////9/f3/+1tbX/tbW1/7W1tf+1tbX/tbW1/97e3v///////////9HR0f+1tbX/tbW1/7W1tf/2 + 9vb/XV1d/zU1Nf/9/f3//////3h4eP90dHT/////////////////UlJS/zMzM/8zMzP/MzMz/5iYmP+q + qqr/MzMz/7Gxsf/v7+//PDw8/zMzM/8zMzP/MzMz/zMzM/8zMzP/Ozs7/+7u7v/Q0ND/NDQ0/zMzM/8z + MzP/MzMz/+Xl5f9dXV3/NTU1//39/f//////eHh4/3R0dP////////////////81NTX/QEBA/6SkpP+n + p6f/09PT/6qqqv8zMzP/sbGx/9ra2v8zMzP/U1NT/6enp/+np6f/p6en/1RUVP8zMzP/2NjY/7S0tP8z + MzP/aGho/6enp/+np6f/9PT0/11dXf81NTX//f39//////94eHj/dHR0/////////////////zU1Nf9b + W1v/////////////////qqqq/zMzM/+xsbH/2tra/zMzM/+BgYH/////////////////g4OD/zMzM//Y + 2Nj/tLS0/zMzM/+np6f/////////////////XV1d/zU1Nf/9/f3//////3h4eP90dHT///////////// + ////NTU1/1tbW/////////////////+qqqr/MzMz/7Gxsf/a2tr/MzMz/4GBgf////////////////+D + g4P/MzMz/9jY2P+0tLT/MzMz/6enp/////////////////9dXV3/NTU1//39/f//////eHh4/3R0dP// + //////////////81NTX/WFhY/////////////////6enp/8zMzP/sbGx/9ra2v8zMzP/fn5+//////// + /////////4CAgP8zMzP/2NjY/7S0tP8zMzP/paWl/////////////////1tbW/81NTX//f39//////94 + eHj/dHR0/////////////////zY2Nv80NDT/WVlZ/1tbW/9bW1v/QUFB/zMzM/+zs7P/3Nzc/zMzM/85 + OTn/W1tb/1tbW/9bW1v/OTk5/zMzM//Z2dn/tbW1/zMzM/9AQED/W1tb/1tbW/9ZWVn/NDQ0/zY2Nv/9 + /f3//////3h4eP90dHT/////////////////iIiI/zc3N/81NTX/NTU1/zU1Nf81NTX/UVFR/+np6f/7 + +/v/Z2dn/zY2Nv81NTX/NTU1/zU1Nf82Njb/ZmZm//r6+v/r6+v/UlJS/zU1Nf81NTX/NTU1/zU1Nf83 + Nzf/hoaG////////////eHh4/3R0dP////////////////////////////7+/v/+/v7//v7+//7+/v// + /////////////////////v7+//7+/v/+/v7//v7+//7+/v///////////////////////v7+//7+/v/+ + /v7//v7+//////////////////////94eHj/dHR0//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3h4eP90dHT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////eHh4/3R0dP////////////////+Q + kJD/xsbG////////////rKys/6Kiov//////z8/P/4KCgv97e3v/tra2////////////sbGx/5OTk//b + 29v/mZmZ/8jIyP///////////+3t7f+Tk5P/eHh4/6CgoP/4+Pj///////////94eHj/dHR0//////// + /////f39/zQ0NP+EhIT//////9LS0v81NTX/fX19/9fX1/83Nzf/g4OD/6Ghof9AQED/wMDA/+3t7f8z + MzP/m5ub/9fX1/9DQ0P/iYmJ///////9/f3/VlZW/1NTU/+bm5v/Q0ND/3l5ef///////////3h4eP90 + dHT////////////9/f3/NDQ0/4SEhP/y8vL/U1NT/0VFRf/q6ur/mZmZ/zMzM/+FhYX/iYmJ/3p6ev/Y + 2Nj/5OTk/zMzM//m5ub//////0NDQ/+Hh4f//////9zc3P8zMzP/ubm5//////+UlJT/Nzc3//39/f// + ////eHh4/3R0dP////////////39/f80NDT/PDw8/zs7O/8zMzP/c3Nz//Pz8/+lpaX/MzMz/6ampv+q + qqr/MzMz/6enp//k5OT/MzMz/+bm5v//////Q0ND/2tra//+/v7/4+Pj/zMzM/+kpKT//////39/f/9C + QkL//v7+//////94eHj/dHR0/////////////f39/zQ0NP9vb2//ycnJ/7Gxsf83Nzf/hoaG//Dw8P9R + UVH/S0tL/0xMTP9NTU3/7Ozs/4SEhP8zMzP/ZmZm//X19f9HR0f/Nzc3/z4+Pv91dXX/iIiI/zc3N/9W + Vlb/NTU1/6ysrP///////////3h4eP90dHT////////////9/f3/NDQ0/3Fxcf/Q0ND/vLy8/zg4OP97 + e3v///////j4+P/Hx8f/xMTE//b29v//////3Nzc/zMzM//Y2Nj//////9TU1P/j4+P/wcHB/+7u7v// + ////2NjY/76+vv/k5OT/////////////////eHh4/3R0dP////////////////9VVVX/OTk5/zk5Of88 + PDz/X19f/+Dg4P/////////////////////////////////19fX/cXFx//X19f////////////////// + //////////////////////////////////////////////94eHj/dHR0//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3h4eP90dHT///////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////eHh4/3R0dP// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////94 + eHj/X19f6v7+/v////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////v7+/2FhYe03NzdcdHR0+qampv+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+n + p6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+n + p6f/p6en/6enp/92dnb7Nzc3YQAAAAA+Pj4aNTU1QDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNANTU1QEBAQBwAAAAAgAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAEoAAAAEAAAACAAAAABACAAAAAAAAAEAAAT + CwAAEwsAAAAAAAAAAAAAWFhYUYeHh4+IiIiPiIiIj4iIiI+IiIiPiIiIj4iIiI+IiIiPiIiIj4iIiI+I + iIiPiIiIj4iIiI+IiIiPWVlZUrS0tPj///////////////////////////////////////////////// + /////////////////////////7a2tvm6urr//////8PDw//Dw8P/w8PD/+fn5/////////////////// + ////9PT0/8PDw//Dw8P/w8PD//Ly8v+8vLz/urq6//////+FhYX/hISE/3BwcP91dXX///////////// + /////////+jo6P+EhIT/hISE/2FhYf+cnJz/vLy8/7q6uv//////ioqK/3R0dP+ysrL/cnJy/8LCwv90 + dHT/dHR0/4CAgP/v7+//e3t7/3R0dP+lpaX/mZmZ/7y8vP+6urr//////0FBQf/S0tL/ycnJ/3Jycv+H + h4f/n5+f/9PT0/9PT0//xsbG/11dXf/T09P/q6ur/5mZmf+8vLz/urq6//////9HR0f//////9TU1P9y + cnL/h4eH/7+/v///////Wlpa/8bGxv9tbW3//////62trf+ZmZn/vLy8/7q6uv//////SkpK/0hISP9C + QkL/iIiI/5ycnP9AQED/SEhI/0JCQv/d3d3/Pz8//0hISP8+Pj7/rq6u/7y8vP+6urr///////////// + //////////////////////////////////////////////////////////////+8vLz/urq6///////V + 1dX//////9PT0//z8/P/v7+//+3t7f/s7Oz/29vb/9jY2P//////4ODg/8XFxf/9/f3/vLy8/7q6uv/+ + /v7/XFxc/8XFxf94eHj/dnZ2/4yMjP+VlZX/jo6O/9bW1v9mZmb/9vb2/2VlZf+cnJz/q6ur/7y8vP+6 + urr//v7+/0RERP96enr/iYmJ/4aGhv96enr/hYWF/3R0dP/Q0ND/S0tL/6Wlpf9mZmb/goKC/7u7u/+8 + vLz/urq6//7+/v9NTU3/gICA/3x8fP/9/f3/4uLi//39/f+dnZ3/8/Pz/+3t7f/r6+v/9fX1/+jo6P// + ////vLy8/7q6uv////////////////////////////////////////////////////////////////// + /////////7y8vP+2trb6//////////////////////////////////////////////////////////// + //////////////+3t7f7YWFhXJCQkJ+QkJCfkJCQn5CQkJ+QkJCfkJCQn5CQkJ+QkJCfkJCQn5CQkJ+Q + kJCfkJCQn5CQkJ+QkJCfYmJiXgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + + + \ No newline at end of file diff --git a/src/RetroGOG/frmPluginSelect.Designer.cs b/src/RetroGOG/frmPluginSelect.Designer.cs new file mode 100644 index 0000000..71b970e --- /dev/null +++ b/src/RetroGOG/frmPluginSelect.Designer.cs @@ -0,0 +1,182 @@ +namespace RetroGOG +{ + partial class frmPluginSelect + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.components = new System.ComponentModel.Container(); + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(frmPluginSelect)); + this.btnBack = new System.Windows.Forms.Button(); + this.btnAbout = new System.Windows.Forms.Button(); + this.btnCancel = new System.Windows.Forms.Button(); + this.btnNext = new System.Windows.Forms.Button(); + this.chbPlaylists = new System.Windows.Forms.CheckedListBox(); + this.lblNoPlaylists = new System.Windows.Forms.Label(); + this.lblRALink = new System.Windows.Forms.LinkLabel(); + this.lblExplain = new System.Windows.Forms.Label(); + this.tmrCheck = new System.Windows.Forms.Timer(this.components); + this.barProgress = new System.Windows.Forms.ProgressBar(); + this.SuspendLayout(); + // + // btnBack + // + this.btnBack.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.btnBack.Location = new System.Drawing.Point(93, 343); + this.btnBack.Name = "btnBack"; + this.btnBack.Size = new System.Drawing.Size(75, 23); + this.btnBack.TabIndex = 13; + this.btnBack.Text = "< < &Back"; + this.btnBack.UseVisualStyleBackColor = true; + this.btnBack.Click += new System.EventHandler(this.btnBack_Click); + // + // btnAbout + // + this.btnAbout.Location = new System.Drawing.Point(12, 343); + this.btnAbout.Name = "btnAbout"; + this.btnAbout.Size = new System.Drawing.Size(75, 23); + this.btnAbout.TabIndex = 12; + this.btnAbout.Text = "&About"; + this.btnAbout.UseVisualStyleBackColor = true; + this.btnAbout.Click += new System.EventHandler(this.btnAbout_Click); + // + // btnCancel + // + this.btnCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.btnCancel.Location = new System.Drawing.Point(543, 343); + this.btnCancel.Name = "btnCancel"; + this.btnCancel.Size = new System.Drawing.Size(75, 23); + this.btnCancel.TabIndex = 11; + this.btnCancel.Text = "&Cancel"; + this.btnCancel.UseVisualStyleBackColor = true; + this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click); + // + // btnNext + // + this.btnNext.Enabled = false; + this.btnNext.Location = new System.Drawing.Point(462, 343); + this.btnNext.Name = "btnNext"; + this.btnNext.Size = new System.Drawing.Size(75, 23); + this.btnNext.TabIndex = 10; + this.btnNext.Text = "&Next > >"; + this.btnNext.UseVisualStyleBackColor = true; + this.btnNext.Click += new System.EventHandler(this.btnNext_Click); + // + // chbPlaylists + // + this.chbPlaylists.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.chbPlaylists.FormattingEnabled = true; + this.chbPlaylists.Location = new System.Drawing.Point(12, 37); + this.chbPlaylists.Name = "chbPlaylists"; + this.chbPlaylists.Size = new System.Drawing.Size(606, 270); + this.chbPlaylists.TabIndex = 16; + // + // lblNoPlaylists + // + this.lblNoPlaylists.AutoSize = true; + this.lblNoPlaylists.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblNoPlaylists.Location = new System.Drawing.Point(12, 9); + this.lblNoPlaylists.Name = "lblNoPlaylists"; + this.lblNoPlaylists.Size = new System.Drawing.Size(446, 18); + this.lblNoPlaylists.TabIndex = 17; + this.lblNoPlaylists.Text = "No Playlists found in Retroarch directory! Please read instructions "; + this.lblNoPlaylists.Visible = false; + // + // lblRALink + // + this.lblRALink.AutoSize = true; + this.lblRALink.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblRALink.Location = new System.Drawing.Point(455, 9); + this.lblRALink.Name = "lblRALink"; + this.lblRALink.Size = new System.Drawing.Size(50, 18); + this.lblRALink.TabIndex = 18; + this.lblRALink.TabStop = true; + this.lblRALink.Text = "HERE"; + this.lblRALink.Visible = false; + this.lblRALink.LinkClicked += new System.Windows.Forms.LinkLabelLinkClickedEventHandler(this.lblRALink_LinkClicked); + // + // lblExplain + // + this.lblExplain.AutoSize = true; + this.lblExplain.Font = new System.Drawing.Font("Microsoft Sans Serif", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lblExplain.Location = new System.Drawing.Point(12, 9); + this.lblExplain.Name = "lblExplain"; + this.lblExplain.Size = new System.Drawing.Size(454, 18); + this.lblExplain.TabIndex = 19; + this.lblExplain.Text = "Please select which systems you would like RetroGOG to configure"; + // + // tmrCheck + // + this.tmrCheck.Tick += new System.EventHandler(this.tmrCheck_Tick); + // + // barProgress + // + this.barProgress.Location = new System.Drawing.Point(12, 314); + this.barProgress.Name = "barProgress"; + this.barProgress.Size = new System.Drawing.Size(606, 23); + this.barProgress.TabIndex = 20; + this.barProgress.Visible = false; + // + // frmPluginSelect + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(630, 378); + this.Controls.Add(this.barProgress); + this.Controls.Add(this.lblExplain); + this.Controls.Add(this.lblRALink); + this.Controls.Add(this.lblNoPlaylists); + this.Controls.Add(this.chbPlaylists); + this.Controls.Add(this.btnBack); + this.Controls.Add(this.btnAbout); + this.Controls.Add(this.btnCancel); + this.Controls.Add(this.btnNext); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; + this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon"))); + this.MaximizeBox = false; + this.Name = "frmPluginSelect"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "RetroGOG"; + this.Load += new System.EventHandler(this.frmPluginSelect_Load); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Button btnBack; + private System.Windows.Forms.Button btnAbout; + private System.Windows.Forms.Button btnCancel; + private System.Windows.Forms.Button btnNext; + private System.Windows.Forms.CheckedListBox chbPlaylists; + private System.Windows.Forms.Label lblNoPlaylists; + private System.Windows.Forms.LinkLabel lblRALink; + private System.Windows.Forms.Label lblExplain; + private System.Windows.Forms.Timer tmrCheck; + private System.Windows.Forms.ProgressBar barProgress; + } +} \ No newline at end of file diff --git a/src/RetroGOG/frmPluginSelect.cs b/src/RetroGOG/frmPluginSelect.cs new file mode 100644 index 0000000..50850e6 --- /dev/null +++ b/src/RetroGOG/frmPluginSelect.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace RetroGOG +{ + public partial class frmPluginSelect : Form + { + public frmPluginSelect() + { + InitializeComponent(); + } + + private void btnCancel_Click(object sender, EventArgs e) + { + if (MessageBox.Show("Are you sure you want to exit the wizard? Any unsaved progress will be lost.", "RetroGOG", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1) == System.Windows.Forms.DialogResult.Yes) + { + this.Close(); + } + } + + private void btnBack_Click(object sender, EventArgs e) + { + this.Hide(); + Form frmDependencies = new frmDependencies(); + frmDependencies.Closed += (s, args) => this.Close(); + frmDependencies.Show(); + } + + private void frmPluginSelect_Load(object sender, EventArgs e) + { + tmrCheck.Enabled = true; + string playlistpath = Globals.RAPath.Replace("retroarch.exe", "playlists\\"); + string[] strPlaylists = Directory.GetFiles(playlistpath); + if (strPlaylists.Count() > 0) + { + foreach (string name in strPlaylists) + { + chbPlaylists.Items.Add(name.Replace(playlistpath, "").Replace(".lpl", ""), true); + } + } + else + { + chbPlaylists.Visible = false; + lblExplain.Visible = false; + lblNoPlaylists.Visible = true; + lblRALink.Visible = true; + btnNext.Enabled = false; + } + } + + private void lblRALink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + { + System.Diagnostics.Process.Start("https://docs.libretro.com/guides/import-content/"); + } + + private void tmrCheck_Tick(object sender, EventArgs e) + { + if (chbPlaylists.CheckedItems.Count == 0) + { + btnNext.Enabled = false; + } + else + { + btnNext.Enabled = true; + } + + if (barProgress.Value == barProgress.Maximum) + { + tmrCheck.Enabled = false; + this.Hide(); + Form frmComplete = new frmComplete(); + frmComplete.Closed += (s, args) => this.Close(); + frmComplete.Show(); + } + } + + private void btnAbout_Click(object sender, EventArgs e) + { + Form frmAbout = new frmAbout(); + frmAbout.Show(); + } + + private void btnNext_Click(object sender, EventArgs e) + { + chbPlaylists.Enabled = false; + btnNext.Enabled = false; + barProgress.Visible = true; + barProgress.Value = 0; + barProgress.Maximum = chbPlaylists.CheckedItems.Count; + + // Build dictionaries + IDictionary plugin_codes = new Dictionary(); + plugin_codes.Add("The 3DO Company - 3DO", "3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577"); + plugin_codes.Add("Nintendo - Nintendo 3DS", "3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55"); + plugin_codes.Add("Atari - 2600", "atari_830528d9-e621-48e9-8ed4-e03a4853843e"); + plugin_codes.Add("Sega - Dreamcast", "dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8"); + plugin_codes.Add("Nintendo - Game Boy", "gb_4345afe1-a2c3-4c58-93d3-373c53a90a92"); + plugin_codes.Add("Atari - Jaguar", "jaguar_b9773549-9c20-4729-b23d-f683762ce73a"); + plugin_codes.Add("Nintendo - Nintendo 64", "n64_a3824d31-c2d3-4a1a-b321-7d0764da5513"); + plugin_codes.Add("Nintendo - GameCube", "ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7"); + plugin_codes.Add("Nintendo - Nintendo DS", "nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc"); + plugin_codes.Add("Nintendo - Nintendo Entertainment System", "nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad"); + plugin_codes.Add("Nintendo - Wii", "nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba"); + plugin_codes.Add("NEC - PC Engine - TurboGrafx 16", "pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a"); + plugin_codes.Add("Sony - PlayStation", "ps1_ff02c67d-5962-4e79-a3a3-928814edb270"); + plugin_codes.Add("Sony - PlayStation 2", "ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea"); + plugin_codes.Add("Sony - PlayStation Portable", "psp_05487532-ba29-411b-b799-784262d275bd"); + plugin_codes.Add("Sega - Saturn", "saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af"); + plugin_codes.Add("Sega - Mega-CD - Sega CD", "segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2"); + plugin_codes.Add("Sega - Mega Drive - Genesis", "segag_e3ac94e7-945e-459d-bc1e-676cff8173f9"); + plugin_codes.Add("Sega - Master System - Mark III", "sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b"); + plugin_codes.Add("Nintendo - Super Nintendo Entertainment System", "snes_bc831044-f772-4391-8c22-529f42cb9799"); + + IDictionary core_codes = new Dictionary(); + core_codes.Add("The 3DO Company - 3DO", "opera_libretro.dll"); + core_codes.Add("Nintendo - Nintendo 3DS", "citra_libretro.dll"); + core_codes.Add("Atari - 2600", "stella_libretro.dll"); + core_codes.Add("Sega - Dreamcast", "flycast_libretro.dll"); + core_codes.Add("Nintendo - Game Boy", "mgba_libretro.dll"); + core_codes.Add("Atari - Jaguar", "virtualjaguar_libretro.dll"); + core_codes.Add("Nintendo - Nintendo 64", "mupen64plus_next_libretro.dll"); + core_codes.Add("Nintendo - GameCube", "dolphin_libretro.dll"); + core_codes.Add("Nintendo - Nintendo DS", "desmume_libretro.dll"); + core_codes.Add("Nintendo - Nintendo Entertainment System", "mesen_libretro.dll"); + core_codes.Add("Nintendo - Wii", "dolphin_libretro.dll"); + core_codes.Add("NEC - PC Engine - TurboGrafx 16", "mednafen_pce_fast_libretro.dll"); + core_codes.Add("Sony - PlayStation", "pcsx_rearmed_libretro.dll"); + core_codes.Add("Sony - PlayStation 2", "play_libretro.dll"); + core_codes.Add("Sony - PlayStation Portable", "ppsspp_libretro.dll"); + core_codes.Add("Sega - Saturn", "mednafen_saturn_libretro.dll"); + core_codes.Add("Sega - Mega-CD - Sega CD", "genesis_plus_gx_libretro.dll"); + core_codes.Add("Sega - Mega Drive - Genesis", "genesis_plus_gx_libretro.dll"); + core_codes.Add("Sega - Master System - Mark III", "genesis_plus_gx_libretro.dll"); + core_codes.Add("Nintendo - Super Nintendo Entertainment System", "snes9x_libretro.dll"); + + + // Download latest galaxy API for plugins + using (var client = new WebClient()) + { + client.Headers.Add("user-agent", "RetroGOG"); + client.DownloadFile("https://raw.githubusercontent.com/jshackles/RetroGOG/master/galaxy_api.zip", Globals.GOGPluginPath + "\\galaxy_api.zip"); + } + + foreach (string item in chbPlaylists.Items) + { + // Create Plugin directory + Directory.CreateDirectory(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\"); + + using (var client = new WebClient()) + { + // Download Plugin from Github + client.Headers.Add("user-agent", "RetroGOG"); + client.DownloadFile("https://raw.githubusercontent.com/jshackles/RetroGOG/master/plugins/" + plugin_codes[item] + "/manifest.json", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "manifest.json"); + client.DownloadFile("https://raw.githubusercontent.com/jshackles/RetroGOG/master/plugins/" + plugin_codes[item] + "/plugin.py", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "plugin.py"); + client.DownloadFile("https://raw.githubusercontent.com/jshackles/RetroGOG/master/plugins/" + plugin_codes[item] + "/version.py", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "version.py"); + + // Download Galaxy API and decompress + if (Directory.Exists(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\galaxy\\")) + { + Directory.Delete(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\galaxy\\",true); + } + System.IO.Compression.ZipFile.ExtractToDirectory(Globals.GOGPluginPath + "\\galaxy_api.zip", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\galaxy\\"); + + // Write user_config.py file + string[] lines = new string[3]; + lines[0] = "# This file automatically generated by RetroGOG"; + lines[1] = "emu_path = \"" + Globals.RAPath.Replace("\\","/").Replace("retroarch.exe","") + "\""; + if (File.Exists(Globals.RAPath.Replace("retroarch.exe", "cores\\") + core_codes[item])) + { + lines[2] = "core = \"" + core_codes[item] + "\""; + } + else + { + Form frmCoreSelect = new frmCoreSelect(); + frmCoreSelect.Text = item; + frmCoreSelect.ShowDialog(this); + lines[2] = "core = \"" + Globals.TempCore + "\""; + } + System.IO.File.WriteAllLines(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "user_config.py", lines); + + barProgress.Value = barProgress.Value + 1; + } + } + } + } +} diff --git a/src/RetroGOG/frmPluginSelect.resx b/src/RetroGOG/frmPluginSelect.resx new file mode 100644 index 0000000..466a7bf --- /dev/null +++ b/src/RetroGOG/frmPluginSelect.resx @@ -0,0 +1,1892 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + + + + 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 + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAICAgAFPT08sPz8/bjMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4A+Pj5xTU1NMnR0dAIA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAImJiQBHR0dEOTk5xzQ0NP4zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk50UVFRVGOjo4AAAAAAAAAAAAAAAAAAAAAAAAAAABs + bGwEPj4+jzQ0NP4zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/z4+PqFdXV0IAAAAAAAAAAAAAAAAZmZmAT09PZ8zMzP/MzMz/zMzM/9iYmL/r6+v/9jY2P/k + 5OT/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l5eX/5eXl/+Xl5f/l + 5eX/5eXl/+Xl5f/l5eX/5OTk/9ra2v+zs7P/aWlp/zMzM/8zMzP/MzMz/zw8PLJ6enoEAAAAAAAAAABC + QkJqMzMz/zMzM/88PDz/tbW1//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////wcHB/0FBQf8zMzP/MzMz/z8/P34AAAAAWlpaEjY2Nu4zMzP/NjY2/8XFxf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////0tLS/zo6Ov8zMzP/NTU19lFRUR8/ + Pz9xMzMz/zMzM/+Kior///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////m5ub/zMzM/8zMzP/Pj4+hjo6OsAzMzP/NTU1/+Xl5f////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////w8PD/Ozs7/zMzM/85 + OTnVQUFB+zMzM/9TU1P///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////9jY2P/MzMz/zY2Nv01NTX/MzMz/2lpaf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3p6ev8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ////////////////////////////////////uLi4/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/vb29/+Li4v////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////9zc3P+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1 + tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7W1tf+1tbX/tbW1/7e3t//MzMz/9/f3//////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NTU1/2lpaf/h4eH///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9DQ0P/p6en//7+/v////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0NDQ//c3Nz///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/iYmJ//////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1tbW//+ + /v7///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////52dnf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/wsLC//////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/83Nzf////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9qamr/////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/n5+f//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/z8/P//8/Pz///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//f39////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////9hYWH/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1FRUf80NDT/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5SUlP////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////sLCw/1tbW/9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9aWlr/Pz8//zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////8DAwP81NTX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////z8/P/Z2dn/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////2hoaP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+UlJT///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////Nzc3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////9zc3P8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////94eHj/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/lJSU//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/2 + 9vb///////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////c + 3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + ///////////////////////////////////////////////x8fH/vLy8/6Wlpf+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo//9/f3/////////////////eHh4/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////////////////////////////////r6+v/I + yMj/qKio/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/o6Oj/6enp//FxcX/+Pj4//////////////////////////////////////// + /////////////////////v7+/9XV1f+tra3/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+jo6P/o6Oj/6Ojo/+j + o6P/o6Oj/6Ojo/+jo6P/0dHR/////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85 + OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v////////////////////////////////////////////////////////////////////////////9 + /f3/nJyc/z09Pf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NDQ0//v7+/////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////// + //////////////////////////////++vr7/SkpK/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9G + Rkb/t7e3/////////////////////////////////////////////////9vb2/9eXl7/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+ZmZn///////////// + ////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + /////////////////////////////////////v7+/4SEhP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/80NDT/+/v7/////////////////3h4eP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT/////////////////////////////////s7Oz/zU1Nf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/80NDT/q6ur//////////////////////// + ///////////////a2tr/QEBA/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5mZmf/////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/OTk5//b29v///////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr///////////////////////////////////////////////////////////////////////C + wsL/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zQ0NP/7+/v/////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP// + /////////////////////////+rq6v89PT3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/86Ojr/5OTk/////////////////////////////f39/1tbW/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ//////// + /////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////25ubv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0//v7+/////////////////94 + eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////oKCg/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+Wlpb///////////// + ///////////////Q0ND/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+ZmZn/////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zk5Of/29vb///////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////RERE/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/80NDT/+/v7/////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+U + lJT///////////////////////////92dnb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2xsbP///////////////////////////6enp/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5mZmf// + ///////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP/7+/v///////////// + ////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ/////////////////9zc3P8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/85OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/R0dH/2pqav9ra2v/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/a2tr//z8/P////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/89 + PT3/aGho/21tbf9tbW3/bW1t/21tbf9tbW3/bW1t/21tbf9tbW3/bW1t/21tbf9paWn/Pj4+/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zY2Nv9gYGD/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/+1 + tbX/////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/2pqav/39/f///////////////////////////////////////////////////////////// + /////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/SEhI/+bm5v////////////////////////////////// + ///////////////////////////////q6ur/TU1N/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/82Njb/x8fH//////// + ///////////////////////////////////////////////////////////////c3Nz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//b29v///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/xcXF//////////////////////// + ////////////////////////////////////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5SUlP///////////////////////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+T + k5P///////////////////////////////////////////////////////////////////////////+a + mpr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi////////////////////////////nZ2d/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2FhYf////////////////////////////////////////////////// + /////////////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + /////////////////////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM//R0dH///////////////////////////////////////////////////////////// + //////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////////////////////// + ////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////////////////////// + /////////////////////////////////////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9i + YmL///////////////////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////// + ////////////////////////////////////////////////////////////////////3Nzc/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9HR0f////////////////// + /////////////////////////////////////////////////////////3h4eP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+UlJT///////////////////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/n5+f//////////////////////////////////////////////////////////////////////// + ////pqam/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv///////////////////////////52dnf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3///////////////////////////////////////////// + ///////////////////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////86Ojr/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/0dHR//////////////////////////////////////////////////////// + ////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP////////////////// + /////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+fn5////////////////////////////// + //////////////////////////////////////////////+mpqb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/YmJi////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/21tbf// + /////////////////////////////////////////////////////////////////////////9zc3P8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + /////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R0dH///////////// + //////////////////////////////////////////////////////////////94eHj/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5+fn/////////////////////////////////////////////////////////////////// + /////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+d + nZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////////////////////////////////////// + ////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/2 + 9vb///////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ////////////////////////////////////////////////////////////////////Ojo6/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/9HR0f////////////////////////////////////////////////// + /////////////////////////3h4eP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////// + //////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/n5+f//////////////////////// + ////////////////////////////////////////////////////pqam/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/2JiYv///////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9t + bW3////////////////////////////////////////////////////////////////////////////c + 3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/0dHR//////// + ////////////////////////////////////////////////////////////////////eHh4/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+fn5////////////////////////////////////////////////////////////// + //////////////+mpqb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////////////////////// + ////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/21tbf////////////////////////////////// + /////////////////////////////////////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85 + OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v//////////////////////////////////////////////////////////////////////zo6Ov8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R0dH///////////////////////////////////////////// + //////////////////////////////94eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU//////// + ////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////// + /////////////////////////////////////////////////////////6ampv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/bW1t//////////////////////////////////////////////////////////////////////// + ////3Nzc/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9HR0f// + /////////////////////////////////////////////////////////////////////////3h4eP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9sbGz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/n5+f//////////////////////////////////////////////////////// + ////////////////////pqam/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv////////////////// + /////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3///////////////////////////// + ///////////////////////////////////////////////c3Nz/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/OTk5//b29v///////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/0dHR//////////////////////////////////////// + ////////////////////////////////////eHh4/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP// + /////////////////////////2xsbP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+fn5////////////// + //////////////////////////////////////////////////////////////+mpqb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/YmJi////////////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/21tbf////////////////////////////////////////////////////////////////// + /////////9zc3P8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/9vb2//////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + /////////////////////////////////////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//R + 0dH///////////////////////////////////////////////////////////////////////////94 + eHj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/lJSU////////////////////////////bGxs/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/5+fn/////////////////////////////////////////////////// + /////////////////////////6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////// + //////////////+dnZ3/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//////////////////////// + ////////////////////////////////////////////////////3Nzc/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zk5Of/29vb///////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + ////Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9DQ0P////////////////////////////////// + /////////////////////////////////////////3Z2dv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+U + lJT///////////////////////////9sbGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/np6e//////// + ////////////////////////////////////////////////////////////////////paWl/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2JiYv///////////////////////////52dnf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + ///////////////b29v/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5//b29v////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////86Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/p6en///////////////////////////////////////////////////////////////////////8 + /Pz/UVFR/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5SUlP///////////////////////////2xsbP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/91dXX///////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////// + ////////////////////nZ2d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/0lJSf/5+fn///////////// + /////////////////////////////////////////////////////////7Kysv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/85OTn/9vb2////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/89PT3/qamp/9PT0//U1NT/1NTU/9TU1P/U + 1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/y8vL/3R0dP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/lJSU////////////////////////////bGxs/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP+N + jY3/0NDQ/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/R0dH/k5OT/zU1Nf8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9iYmL///////////////////////////+dnZ3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/21tbf/Jycn/1NTU/9TU1P/U1NT/1NTU/9TU1P/U1NT/1NTU/9TU1P/U + 1NT/1NTU/9PT0/+urq7/QEBA/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zk5Of/29vb///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////Ojo6/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+UlJT///////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2JiYv// + /////////////////////////52dnf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/OTk5//b29v///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////88PDz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/5aWlv///////////////////////////25ubv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ZGRk////////////////////////////n5+f/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr/+Pj4//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + /////////////////////////////////////////////////////////1NTU/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////////////////////// + ////hYWF/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/97 + e3v///////////////////////////+2trb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0pKSv////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ////////////////////kJCQ/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zY2Nv/n5+f////////////////////////////CwsL/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/7m5uf///////////////////////////+3t7f86 + Ojr/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/h4eH//////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + ///////////////////////////////////////////////////////////////s7Oz/RUVF/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/jIyM//////////////////////// + //////////39/f9lZWX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9e + Xl7/+/v7/////////////////////////////////5WVlf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0FBQf/n5+f///////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ///////////////////////////////S0tL/RERE/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/3d3d//5+fn//////////////////////////////////////+3t7f9cXFz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/V1dX/+np6f////////////////////////////////// + /////Pz8/39/f/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9CQkL/zMzM//////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ///////////////////////////////////////////////////////////////////////////////s + 7Oz/kJCQ/1JSUv89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/QkJC/2lpaf+6urr//v7+//////////////////////// + //////////////////////////n5+f+mpqb/Xl5e/z8/P/89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf8/Pz//XFxc/6Kiov/3 + 9/f//////////////////////////////////////////////////v7+/7+/v/9ra2v/Q0ND/z09Pf89 + PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89PT3/PT09/z09Pf89 + PT3/PT09/z09Pf9RUVH/jY2N/+np6f////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////39/f/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/+ + /v7///////////////////////////////////////////////////////////////////////////// + /////v7+//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//7+/v////////////////////////////////////////////////// + /////////////////////////////////////Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8 + /Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//z8/P/8/Pz//f39//////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ///////////////////////////////IyMj/gICA/3Z2dv+lpaX/+Pj4//////////////////////// + /////////////////////////////////////////8rKyv+BgYH/dnZ2/6Ghof/19fX///////////// + ///////////////////////////////+/v7/4eHh/66urv+MjIz/eXl5/3Jycv94eHj/i4uL/66urv/k + 5OT///////////////////////////////////////////////////////////////////////z8/P/p + 6en/2NjY/9jY2P/k5OT/+Pj4////////////////////////////3t7e/4iIiP92dnb/pKSk//j4+P// + ///////////////////////////////////////////////////////////////////////////////x + 8fH/vLy8/5OTk/97e3v/c3Nz/3h4eP+Li4v/sLCw/+Xl5f////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ////////////////////////////////////////////////////////////////////t7e3/zU1Nf8z + MzP/MzMz/zMzM/9qamr//v7+//////////////////////////////////////////////////////+n + p6f/NTU1/zMzM/8zMzP/MzMz/15eXv/6+vr/////////////////////////////////wcHB/19fX/80 + NDT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zU1Nf9lZWX/yMjI//////////////////////// + //////////////////////////v7+/+dnZ3/SUlJ/zMzM/8zMzP/MzMz/zMzM/88PDz/b29v/9nZ2f// + /////////+Li4v8/Pz//MzMz/zMzM/8zMzP/cnJy//7+/v////////////////////////////////// + ///////////////////////////////o6Oj/hISE/zs7O/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NTU1/2lpaf/Pz8////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////9VVVX/MzMz/zMzM/8zMzP/MzMz/zMzM//Q0ND///////////// + ////////////////////////////////////0dHR/zc3N/8zMzP/MzMz/zMzM/8zMzP/MzMz/8fHx/// + ////////////////////9/f3/3t7e/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/80NDT/hoaG//r6+v//////////////////////////////////////hISE/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/RERE//f39///////kZGR/zMzM/8zMzP/MzMz/zMzM/8z + MzP/3d3d////////////////////////////////////////////////////////////vLy8/z8/P/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP+Pj4///Pz8//////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v/////////////////////////////////////////////////////////////////+/v7/zc3N/8z + MzP/MzMz/zMzM/8zMzP/MzMz/6+vr/////////////////////////////////////////////j4+P9U + VFT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/19fX//////////////////r6+v9sbGz/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/f39///////// + /////////////////////////+bm5v82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/83 + Nzf/8PDw//////91dXX/MzMz/zMzM/8zMzP/MzMz/zMzM//CwsL///////////////////////////// + /////////////////////////7W1tf82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9/f3///v7+//////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ///////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////// + ////////////////////////////////////lJSU/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1hYWP/9 + /f3/////////////////lJSU/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/0xMTP+Hh4f/mZmZ/4eHh/9Q + UFD/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/yMjI////////////////////////////ubm5/zMzM/8z + MzP/MzMz/zMzM/82Njb/iIiI/5ubm/9/f3//ampq/7W1tf///////////3Nzc/8zMzP/MzMz/zMzM/8z + MzP/MzMz/7+/v//////////////////////////////////////////////////a2tr/OTk5/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0RERP9aWlr/S0tL/zQ0NP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+o + qKj//////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////j4+P82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz//////////////////////////////////////9jY2P84 + ODj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/t7e3/////////////////+bm5v85OTn/MzMz/zMzM/8z + MzP/MzMz/zMzM/+Ojo7/+fn5//////////////////z8/P+urq7/Pj4+/zMzM/8zMzP/MzMz/zMzM/+K + ior///////////////////////////+hoaH/MzMz/zMzM/8zMzP/MzMz/3d3d/////////////////// + ////////////////////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/v7+///////////////////////// + /////////////////////v7+/2lpaf8zMzP/MzMz/zMzM/8zMzP/MzMz/0JCQv+9vb3/+/v7///////+ + /v7/1tbW/1lZWf8zMzP/MzMz/zMzM/8zMzP/MzMz/0BAQP/w8PD///////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + ////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6ysrP// + ///////////////////////////////7+/v/XV1d/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/15eXv/9 + /f3/////////////////l5eX/zMzM/8zMzP/MzMz/zMzM/8zMzP/bW1t//7+/v////////////////// + ///////////////d3d3/UFBQ/zMzM/8zMzP/MzMz/6Ojo////////////////////////////5eXl/8z + MzP/MzMz/zMzM/8zMzP/lZWV//////////////////////////////////////9zc3P/MzMz/zMzM/8z + MzP/MzMz/zMzM/+/v7/////////////////////////////////////////////b29v/NDQ0/zMzM/8z + MzP/MzMz/zMzM/82Njb/z8/P////////////////////////////8PDw/0tLS/8zMzP/MzMz/zMzM/8z + MzP/MzMz/6Wlpf////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra//////////////////////////////////////////////////////////////////4 + +Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys/////////////////////////////////6CgoP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/84ODj/1tbW//////////////////////9cXFz/MzMz/zMzM/8z + MzP/MzMz/zMzM//Kysr////////////////////////////////////////////u7u7/hISE/2dnZ/+X + l5f/+vr6////////////////////////////lZWV/zMzM/8zMzP/MzMz/zMzM/+ampr///////////// + /////////////////////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/7+/v/////////////////// + /////////////////////////5ycnP8zMzP/MzMz/zMzM/8zMzP/MzMz/3Nzc/////////////////// + ////////////////////q6ur/zMzM/8zMzP/MzMz/zMzM/8zMzP/ZmZm//////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+s + rKz////////////////////////////a2tr/Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5qamv// + ////////////////////+fn5/zw8PP8zMzP/MzMz/zMzM/8zMzP/NDQ0/7S0tP++vr7/vr6+/76+vv++ + vr7/vr6+/76+vv++vr7/vr6+/7+/v//Ly8v/6urq//////////////////////////////////////+V + lZX/MzMz/zMzM/8zMzP/MzMz/5qamv//////////////////////////////////////c3Nz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/vr6+////////////////////////////////////////////dHR0/zMzM/8z + MzP/MzMz/zMzM/8zMzP/sbGx///////////////////////////////////////p6en/MzMz/zMzM/8z + MzP/MzMz/zMzM/9AQED//Pz8////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + ////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6ysrP//////////////////////7+/v/1JSUv8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9tbW3//f39///////////////////////u7u7/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/81 + NTX/ZmZm/+rq6v///////////////////////////5WVlf8zMzP/MzMz/zMzM/8zMzP/mpqa//////// + //////////////////////////////9zc3P/MzMz/zMzM/8zMzP/MzMz/zMzM/+4uLj///////////// + //////////////////////////////9eXl7/MzMz/zMzM/8zMzP/MzMz/zMzM//Pz8////////////// + //////////////////////////////87Ozv/MzMz/zMzM/8zMzP/MzMz/zY2Nv/w8PD///////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ///////////////////////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8z + MzP/rKys/////////////////+Dg4P9YWFj/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/W1tb//Pz8/// + /////////////////////////+np6f8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/fHx8//////////////////////// + ////lZWV/zMzM/8zMzP/MzMz/zMzM/+ampr//////////////////////////////////////3Nzc/8z + MzP/MzMz/zMzM/8zMzP/MzMz/62trf///////////////////////////////////////////1lZWf8z + MzP/MzMz/zMzM/8zMzP/MzMz/9bW1v///////////////////////////////////////////0FBQf8z + MzP/MzMz/zMzM/8zMzP/NDQ0/+zs7P///////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + //////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/9mZmb/iYmJ/4KCgv9lZWX/NjY2/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/2JiYv/v7+//////////////////////////////////8/Pz/zQ0NP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9NTU3///////////////////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv// + ////////////////////////////////////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/mZmZ//////// + ////////////////////////////////////ZGRk/zMzM/8zMzP/MzMz/zMzM/8zMzP/x8fH//////// + ///////////////////////////////7+/v/NjY2/zMzM/8zMzP/MzMz/zMzM/84ODj/9PT0//////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + ////////////////////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/83Nzf/mpqa/+np6f// + ///////////////////////////////9/f3/RkZG/zMzM/8zMzP/MzMz/zMzM/8zMzP/m5ub/6urq/+r + q6v/q6ur/6urq/+rq6v/q6ur/6ampv8zMzP/MzMz/zMzM/8zMzP/MzMz/01NTf////////////////// + /////////5WVlf8zMzP/MzMz/zMzM/8zMzP/mpqa//////////////////////////////////////9z + c3P/MzMz/zMzM/8zMzP/MzMz/zMzM/92dnb///////////////////////////////////////////+A + gID/MzMz/zMzM/8zMzP/MzMz/zMzM/+enp7//////////////////////////////////////9fX1/8z + MzP/MzMz/zMzM/8zMzP/MzMz/0pKSv////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + ///////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/OTk5/4qKiv/09PT///////////////////////////9y + cnL/MzMz/zMzM/8zMzP/MzMz/zMzM/+zs7P/////////////////////////////////ysrK/zMzM/8z + MzP/MzMz/zMzM/8zMzP/aWlp////////////////////////////lZWV/zMzM/8zMzP/MzMz/zMzM/+a + mpr//////////////////////////////////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/0NDQ//5 + +fn//////////////////////////////////////7CwsP8zMzP/MzMz/zMzM/8zMzP/MzMz/1VVVf/8 + /Pz/////////////////////////////////ioqK/zMzM/8zMzP/MzMz/zMzM/8zMzP/enp6//////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////j4+P82Njb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/1dXV//r6+v//////////////////////7a2tv8zMzP/MzMz/zMzM/8zMzP/MzMz/1FRUf/z + 8/P///////////////////////v7+/9mZmb/MzMz/zMzM/8zMzP/MzMz/zMzM/+jo6P///////////// + //////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv////////////////////////////////// + ////c3Nz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5ycnP/////////////////z8/P/0dHR/+7u7v// + ////7+/v/zs7O/8zMzP/MzMz/zMzM/8zMzP/MzMz/5iYmP/+/v7//////////////////////8fHx/84 + ODj/MzMz/zMzM/8zMzP/MzMz/zMzM//BwcH///////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + ////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/2VlZf/+/v7///////////// + ////+fn5/05OTv8zMzP/MzMz/zMzM/8zMzP/MzMz/1lZWf/FxcX/8vLy//Pz8//Ozs7/aGho/zMzM/8z + MzP/MzMz/zMzM/8zMzP/Pz8//+/v7////////////////////////////5WVlf8zMzP/MzMz/zMzM/8z + MzP/mpqa//////////////////////////////////////9zc3P/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/NDQ0/2RkZP99fX3/YmJi/zk5Of8zMzP/PDw8/7y8vP//////jIyM/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/3Jycv/CwsL/29vb/8zMzP+NjY3/ODg4/zMzM/8zMzP/MzMz/zMzM/8zMzP/W1tb//39/f// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + ///////////////////////////////////////////////////////////////4+Pj/NjY2/zMzM/8z + MzP/MzMz/zMzM/8zMzP/pKSk//Ly8v/y8vL/8vLy//Ly8v/u7u7/4eHh/8XFxf+RkZH/Pz8//zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/8LCwv//////////////////////xMTE/zU1Nf8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/82Njb/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+oqKj///////////// + ////9vb2/3Nzc/88PDz/NjY2/zMzM/8zMzP/MzMz/zMzM/82Njb/OTk5/0ZGRv92dnb/7e3t//////// + /////////3Nzc/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/RUVF//v7+//z8/P/UFBQ/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zs7O//W1tb///////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////j4+P82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz///////////// + ///////////////////////////////c3Nz/Ojo6/zMzM/8zMzP/MzMz/zMzM/8zMzP/f39///////// + ////////////////////paWl/zQ0NP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/hoaG//7+/v////////////////+2trb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+goKD/////////////////fn5+/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/85OTn/+Pj4///////f39//SUlJ/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr/v7+///////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v/////////////////////////////////////////////////////////////////+Pj4/zY2Nv8z + MzP/MzMz/zMzM/8zMzP/MzMz/6ysrP////////////////////////////////////////////////9s + bGz/MzMz/zMzM/8zMzP/MzMz/zMzM/9cXFz/////////////////////////////////vLy8/0ZGRv8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Ozs7/6SkpP/+/v7///////////// + /////////9zc3P88PDz/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NjY2/8nJyf// + //////////////+tra3/MzMz/zMzM/8zMzP/MzMz/0pKSv9dXV3/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/4eHh//////////////////q6ur/bW1t/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/VlZW/9TU1P////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + ///////////////////////////////4+Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/rKys//////// + /////////////////////////////////////////3BwcP8zMzP/MzMz/zMzM/8zMzP/MzMz/1NTU/// + ////////////////////////////////////8vLy/6Kiov9aWlr/NTU1/zMzM/8zMzP/MzMz/zMzM/80 + NDT/SUlJ/46Ojv/m5ub//////////////////////////////////////+fn5/+4uLj/cHBw/zMzM/8z + MzP/MzMz/zMzM/9zc3P/srKy/7S0tP/d3d3///////////////////////j4+P9kZGT/MzMz/zMzM/84 + ODj/urq6//Dw8P9ycnL/NDQ0/zMzM/8zMzP/MzMz/01NTf+oqKj//Pz8///////////////////////+ + /v7/ysrK/3d3d/8+Pj7/MzMz/zMzM/8zMzP/MzMz/zMzM/84ODj/Z2dn/7W1tf/6+vr///////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////j4+P82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/+srKz////////////////////////////////////////////r + 6+v/Pz8//zMzM/8zMzP/MzMz/zMzM/8zMzP/YmJi//////////////////////////////////////// + ///////////////w8PD/0dHR/8HBwf++vr7/ysrK/+Xl5f/+/v7///////////////////////////// + //////////////////////////////+VlZX/MzMz/zMzM/8zMzP/MzMz/5qamv////////////////// + //////////////////////////z8/P/Pz8//wMDA/+jo6P/////////////////k5OT/wsLC/8HBwf/d + 3d3//v7+//////////////////////////////////////////////////v7+//d3d3/xsbG/729vf/D + w8P/19fX//b29v////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + ////////////////////////////////////+Pj4/zY2Nv8zMzP/MzMz/zMzM/8zMzP/MzMz/6urq//9 + /f3//f39//39/f/9/f3//f39//v7+//z8/P/xMTE/1VVVf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+N + jY3///////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////5WVlf8z + MzP/MzMz/zMzM/8zMzP/mpqa//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra//////////////////////////////////////////////////////////////////4 + +Pj/NjY2/zMzM/8zMzP/MzMz/zMzM/8zMzP/Pj4+/0VFRf9FRUX/RUVF/0VFRf9ERET/Pj4+/zQ0NP8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0/9jY2P////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////lpaW/zMzM/8zMzP/MzMz/zMzM/+ampr///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////r6+v82Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+C + goL///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////+c + nJz/MzMz/zMzM/8zMzP/MzMz/6Kiov////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/MzMz/zMzM/9ra2v///////////////////////////////////////////////////////////// + /////////0tLS/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ampq//f39/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/zMzM/8zMzP/xMTE//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////// + ////////////////////////////////////////////////////oaGh/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/PT09/5iYmP/7 + +/v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////f39/4aGhv85OTn/Ozs7/4+Pj//+/v7///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////3x8fP8z + MzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////////////////////// + ///////////////9/f3/sLCw/2dnZ/9PT0//TU1N/01NTf9NTU3/TU1N/01NTf9NTU3/TU1N/01NTf9N + TU3/T09P/1ZWVv9lZWX/gICA/7CwsP/w8PD///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////X19f/29vb///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////98 + fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9r + a2v///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////98fHz/MzMz/zMzM/8zMzP/MzMz/2tra/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3x8fP8zMzP/MzMz/zMzM/8z + MzP/a2tr//////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////fHx8/zMzM/8zMzP/MzMz/zMzM/9ra2v///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////98fHz/MzMz/zMzM/8z + MzP/MzMz/2tra/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////3x8fP8zMzP/MzMz/zMzM/8zMzP/a2tr//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////fHx8/zMzM/8z + MzP/Ozs7/zMzM/9fX1////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////9wcHD/MzMz/zMzM/89PT3cMzMz/z4+Pv/39/f///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////Pz8/0pKSv8z + MzP/OTk57Dw8PJUzMzP/MzMz/7W1tf////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////Gxsb/MzMz/zMzM/87OzuqS0tLNjQ0NP0zMzP/TExM//Hx8f// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////9/f3/1dXV/8z + MzP/MzMz/0VFRUmNjY0APDw8rTMzM/8zMzP/ZGRk//Hx8f////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////b29v9wcHD/MzMz/zMzM/86OjrAgYGBAgAAAABTU1MYNzc34zMzM/8z + MzP/TExM/7a2tv/39/f///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////r6+v++vr7/U1NT/zMzM/8z + MzP/NjY27E1NTSMAAAAAAAAAAAAAAABKSkoqNzc34jMzM/8zMzP/MzMz/z4+Pv9gYGD/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9sbGz/bGxs/2xsbP9s + bGz/bGxs/2xsbP9iYmL/QUFB/zMzM/8zMzP/MzMz/zY2NutHR0c3AAAAAAAAAAAAAAAAAAAAAAAAAABT + U1MXPDw8rDQ0NP0zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zQ0NP47 + Ozu5TU1NIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACNjY0AS0tLNTw8PJU9PT3bOzs7/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/Ojo6/0BAQOM8PDycR0dHPo+PjwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////////////////// + ////////////////////+AAAAAAAAAAAAAAAAAAAH+AAAAAAAAAAAAAAAAAAAAfAAAAAAAAAAAAAAAAA + AAADgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAHA + AAAAAAAAAAAAAAAAAAAD4AAAAAAAAAAAAAAAAAAAB/AAAAAAAAAAAAAAAAAAAA////////////////// + //////////////////////////////////////////////8oAAAAQAAAAIAAAAABACAAAAAAAABAAAAT + CwAAEwsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgICAAENDQyYzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQEJCQil0dHQAAAAAAAAAAAAAAAAAPz8/JTc3N8Iz + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Nzc3yD8/PyoA + AAAAQkJCGzU1NedWVlb/xMTE/+/v7//y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y + 8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/y8vL/8vLy//Ly8v/v + 7+//x8fH/1paWv81NTXsQUFBIDg4OJxKSkr/8PDw//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////09PT/T09P/zc3N6Y4ODjum5ub//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////6Ojo/81NTX0MzMz/7W1tf// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////+9 + vb3/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////++vr7/MzMz/zMzM/+1 + tbX/////////////////////////////////29vb/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a + 2tr/2tra/9ra2v/a2tr/5+fn//////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////k5OT/2tra/9ra2v/a + 2tr/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr/2tra/9ra2v/g4OD//f39//////////////////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9wcHD/9vb2//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////aGho/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/1RUVP/h + 4eH//////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////83 + Nzf/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/5aWlv// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////2hoaP8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/ZWVl//////////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ////////////////////Nzc3/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/9mZmb///////////////////////////////////////////////////////////// + //////////////////////////////////////////////9oaGj/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zg4OP/8/Pz/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////////66urv+tra3/ra2t/62trf+tra3/ra2t/62trf+t + ra3/ra2t/62trf+RkZH/NDQ0/zMzM/8zMzP/Y2Nj//////////////////////////////////////// + ////////////////////////////////////////////////////////////////////wsLC/62trf+t + ra3/ra2t/62trf+tra3/ra2t/62trf+tra3/ra2t/6Ojo/9AQED/MzMz/zMzM/82Njb/+/v7//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////////////////////// + /////////////////////////////////////////1FRUf8zMzP/MzMz/2NjY/////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////hISE/zMzM/8z + MzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////// + //////////////////////////////////////////////////////////////9VVVX/MzMz/zMzM/9j + Y2P///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////4iIiP8zMzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8zMzP/tbW1//////// + //////////////////////////7+/v+ysrL/cnJy/2tra/9ra2v/a2tr/2tra/9ra2v/a2tr//39/f// + ////VVVV/zMzM/8zMzP/Y2Nj/////////////////+/v7/+Pj4//bGxs/2tra/9ra2v/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/bGxs/46Ojv/t7e3//////////////////////87Ozv96enr/a2tr/2tra/9r + a2v/a2tr/2tra/9ra2v/2tra//////+IiIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////////+enp7/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM//9/f3//////1VVVf8zMzP/MzMz/2NjY/////////////r6+v9WVlb/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/U1NT//j4+P///////////8zMzP82 + Njb/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/8zMzP//////iIiI/zMzM/8zMzP/NjY2//v7+/// + //////////////++vr7/MzMz/zMzM/+1tbX/////////////////////////////////RkZG/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP//f39//////9VVVX/MzMz/zMzM/9jY2P////////////F + xcX/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//A + wMD///////////93d3f/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM//MzMz//////4iIiP8z + MzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + /////////zc3N/8zMzP/MzMz/zMzM/9GRkb/T09P/09PT/9PT0//T09P//39/f//////VVVV/zMzM/8z + MzP/Y2Nj////////////tra2/zMzM/8zMzP/MzMz/zU1Nf9PT0//UFBQ/1BQUP9QUFD/UFBQ/09PT/82 + Njb/MzMz/zMzM/8zMzP/sbGx////////////aGho/zMzM/8zMzP/MzMz/z8/P/9PT0//T09P/09PT/9P + T0//09PT//////+IiIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////83Nzf/MzMz/zMzM/9lZWX//f39//////////////////////// + /////////1VVVf8zMzP/MzMz/2NjY////////////7a2tv8zMzP/MzMz/zMzM/+wsLD///////////// + ////////////////////tLS0/zMzM/8zMzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/z8/P//x + 8fH/////////////////////////////////iIiI/zMzM/8zMzP/NjY2//v7+/////////////////++ + vr7/MzMz/zMzM/+1tbX/////////////////////////////////Nzc3/zMzM/8zMzP/goKC//////// + //////////////////////////////9VVVX/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8z + MzP/z8/P/////////////////////////////////9LS0v8zMzP/MzMz/zMzM/+xsbH///////////9o + aGj/MzMz/zMzM/9QUFD//////////////////////////////////////4iIiP8zMzP/MzMz/zY2Nv/7 + +/v/////////////////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8z + MzP/MzMz/4KCgv//////////////////////////////////////VVVV/zMzM/8zMzP/Y2Nj//////// + ////tra2/zMzM/8zMzP/MzMz/8/Pz//////////////////////////////////S0tL/MzMz/zMzM/8z + MzP/sbGx////////////aGho/zMzM/8zMzP/UFBQ//////////////////////////////////////+I + iIj/MzMz/zMzM/82Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////83Nzf/MzMz/zMzM/+CgoL//////////////////////////////////////1VVVf8z + MzP/MzMz/2NjY////////////7a2tv8zMzP/MzMz/zMzM//Pz8////////////////////////////// + ////0tLS/zMzM/8zMzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/1BQUP////////////////// + ////////////////////iIiI/zMzM/8zMzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1 + tbX/////////////////////////////////Nzc3/zMzM/8zMzP/goKC//////////////////////// + //////////////9VVVX/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8zMzP/z8/P//////// + /////////////////////////9LS0v8zMzP/MzMz/zMzM/+xsbH///////////9oaGj/MzMz/zMzM/9Q + UFD//////////////////////////////////////4iIiP8zMzP/MzMz/zY2Nv/7+/v///////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////////zc3N/8zMzP/MzMz/4KCgv// + ////////////////////////////////////VVVV/zMzM/8zMzP/Y2Nj////////////tra2/zMzM/8z + MzP/MzMz/8/Pz//////////////////////////////////S0tL/MzMz/zMzM/8zMzP/sbGx//////// + ////aGho/zMzM/8zMzP/UFBQ//////////////////////////////////////+IiIj/MzMz/zMzM/82 + Njb/+/v7/////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////83 + Nzf/MzMz/zMzM/93d3f//////////////////////////////////v7+/0tLS/8zMzP/MzMz/2NjY/// + /////////7a2tv8zMzP/MzMz/zMzM//ExMT/////////////////////////////////yMjI/zMzM/8z + MzP/MzMz/7Gxsf///////////2hoaP8zMzP/MzMz/0dHR//9/f3///////////////////////////// + ////fX19/zMzM/8zMzP/NjY2//v7+/////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ////////////////////Nzc3/zMzM/8zMzP/NjY2/3h4eP+EhIT/hISE/4SEhP+EhIT/hISE/2lpaf8z + MzP/MzMz/zMzM/9jY2P///////////+2trb/MzMz/zMzM/8zMzP/SkpK/4KCgv+EhIT/hISE/4SEhP+E + hIT/g4OD/0xMTP8zMzP/MzMz/zMzM/+xsbH///////////9oaGj/MzMz/zMzM/8zMzP/Z2dn/4SEhP+E + hIT/hISE/4SEhP+EhIT/enp6/zY2Nv8zMzP/MzMz/zY2Nv/7+/v/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////////z09Pf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/ampq////////////vLy8/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/t7e3////////////b29v/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/86Ojr//f39//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////99fX3/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/6qqqv///////////+/v7/9A + QED/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/Pj4+/+zs7P// + /////////6+vr/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/eHh4//////////////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////// + ////9PT0/319ff89PT3/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/RERE/5iYmP/+ + /v7/////////////////0NDQ/1paWv84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84 + ODj/WVlZ/83Nzf/////////////////+/v7/nJyc/0VFRf84ODj/ODg4/zg4OP84ODj/ODg4/zg4OP84 + ODj/ODg4/zg4OP89PT3/e3t7//Ly8v//////////////////////vr6+/zMzM/8zMzP/tbW1//////// + //////////////////////////////////////////7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+/////////////////////////////////////////////v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+/////////////////////////////////////////////v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//////////////////////////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////++vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////++ + vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////Hx8f+9 + vb3/5+fn/////////////////////////////////9LS0v/FxcX//Pz8///////////////////////j + 4+P/wcHB/7q6uv/Ozs7/+Pj4//////////////////////////////////n5+f/r6+v/9/f3//////// + ////9/f3/7+/v//n5+f///////////////////////////////////////z8/P/T09P/u7u7/8DAwP/l + 5eX//////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////9dXV3/MzMz/0FBQf/z8/P//////////////////////6urq/8zMzP/MzMz/5SUlP// + /////////9zc3P9iYmL/MzMz/zMzM/8zMzP/MzMz/0BAQP+goKD//v7+/////////////////5SUlP85 + OTn/MzMz/zU1Nf9wcHD//f39/3l5ef8zMzP/Q0ND//b29v///////////////////////////7m5uf9J + SUn/MzMz/zMzM/8zMzP/MzMz/2hoaP/i4uL///////////////////////////++vr7/MzMz/zMzM/+1 + tbX////////////////////////////8/Pz/NTU1/zMzM/8zMzP/1tbW/////////////////+Li4v87 + Ozv/MzMz/zMzM/+YmJj//////+Pj4/9BQUH/MzMz/zMzM/9OTk7/YWFh/zo6Ov8zMzP/MzMz/56env// + /////////+fn5/80NDT/MzMz/0lJSf9gYGD/YmJi//v7+/9UVFT/MzMz/zMzM//g4OD///////////// + /////////7Kysv80NDT/MzMz/zMzM/9BQUH/OTk5/zMzM/8zMzP/RkZG/+np6f////////////////// + ////vr6+/zMzM/8zMzP/tbW1/////////////////////////////Pz8/zQ0NP8zMzP/MzMz/9XV1f// + //////////7+/v9oaGj/MzMz/zMzM/8+Pj7/7e3t//////96enr/MzMz/zMzM/+Li4v//v7+///////q + 6ur/aGho/zMzM/9lZWX////////////Ozs7/MzMz/zMzM//Dw8P/////////////////U1NT/zMzM/8z + MzP/39/f//////////////////b29v9BQUH/MzMz/zQ0NP+zs7P//v7+//T09P9ycnL/MzMz/zMzM/+C + goL//////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////z8/P80 + NDT/MzMz/zMzM//V1dX///////////+tra3/MzMz/zMzM/8zMzP/qqqq///////+/v7/QEBA/zMzM/8z + MzP/z8/P/97e3v/e3t7/3t7e/9ra2v+oqKj/5OTk////////////ysrK/zMzM/8zMzP/zMzM//////// + /////////1NTU/8zMzP/MzMz/9/f3//////////////////Dw8P/MzMz/zMzM/9iYmL///////////// + ////5OTk/zMzM/8zMzP/Q0ND//7+/v////////////////++vr7/MzMz/zMzM/+1tbX///////////// + ///////////////8/Pz/NDQ0/zMzM/8zMzP/1dXV///////Jycn/Ozs7/zMzM/8zMzP/e3t7//7+/v// + ////9fX1/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/4CAgP///////////8rKyv8z + MzP/MzMz/8zMzP////////////////9TU1P/MzMz/zMzM//Z2dn/////////////////ra2t/zMzM/8z + MzP/g4OD//////////////////////84ODj/MzMz/zQ0NP/39/f/////////////////vr6+/zMzM/8z + MzP/tbW1/////////////////////////////Pz8/zQ0NP8zMzP/MzMz/1VVVf9TU1P/NDQ0/zMzM/8z + MzP/WVlZ//b29v////////////v7+/84ODj/MzMz/zMzM/9ra2v/b29v/29vb/9ubm7/MzMz/zMzM/9A + QED////////////Kysr/MzMz/zMzM//MzMz/////////////////U1NT/zMzM/8zMzP/w8PD//////// + /////////7i4uP8zMzP/MzMz/3Nzc//////////////////09PT/NDQ0/zMzM/86Ojr//Pz8//////// + /////////76+vv8zMzP/MzMz/7W1tf////////////////////////////z8/P80NDT/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9KSkr/zc3N////////////ZGRk/zMzM/8zMzP/vb29//////// + ////ysrK/zMzM/8zMzP/XV1d////////////ysrK/zMzM/8zMzP/zMzM/////////////////1NTU/8z + MzP/MzMz/4ODg////////Pz8/+/v7//n5+f/NTU1/zMzM/88PDz/5OTk////////////oqKi/zMzM/8z + MzP/aGho//////////////////////++vr7/MzMz/zMzM/+1tbX////////////////////////////8 + /Pz/NDQ0/zMzM/8zMzP/f39//5KSkv+SkpL/g4OD/01NTf8zMzP/MzMz/z8/P//v7+///////8PDw/80 + NDT/MzMz/zw8PP+IiIj/i4uL/0BAQP8zMzP/MzMz/7W1tf//////2tra/4GBgf8zMzP/MzMz/4KCgv+u + rq7/+vr6//////9TU1P/MzMz/zMzM/8zMzP/UlJS/0BAQP81NTX/v7+//4GBgf8zMzP/MzMz/0NDQ/+B + gYH/cHBw/zQ0NP8zMzP/NTU1/8vLy///////////////////////vr6+/zMzM/8zMzP/tbW1//////// + /////////////////////Pz8/zQ0NP8zMzP/MzMz/9XV1f/////////////////29vb/Q0ND/zMzM/8z + MzP/tra2////////////paWl/zg4OP8zMzP/MzMz/zMzM/8zMzP/NTU1/5eXl////////////4CAgP8z + MzP/MzMz/zMzM/8zMzP/NDQ0/9ra2v//////ZGRk/zMzM/85OTn/Pj4+/zMzM/8zMzP/MzMz/66urv/3 + 9/f/dXV1/zMzM/8zMzP/MzMz/zMzM/8zMzP/PDw8/7Ozs////////////////////////////76+vv8z + MzP/MzMz/7W1tf////////////////////////////z8/P80NDT/MzMz/zMzM//V1dX///////////// + ////+vr6/0VFRf8zMzP/MzMz/62trf/////////////////l5eX/n5+f/35+fv97e3v/mJiY/9zc3P// + ///////////////5+fn/r6+v/zMzM/8zMzP/sLCw/+Tk5P///////////9bW1v99fX3/tra2/9jY2P+D + g4P/gYGB/7y8vP/+/v7////////////Q0ND/k5OT/3p6ev+AgID/paWl/+vr6/////////////////// + //////////////++vr7/MzMz/zMzM/+1tbX////////////////////////////8/Pz/NDQ0/zMzM/8z + MzP/i4uL/6Ghof+hoaH/mJiY/2BgYP8zMzP/MzMz/zMzM//Z2dn///////////////////////////// + /////////////////////////////////////////8rKyv8zMzP/MzMz/83Nzf////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////// + /////v7+/zo6Ov8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+FhYX///////////// + ///////////////////////////////////////////////////////////////W1tb/MzMz/zMzM//Z + 2dn///////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf// + //////////////////////////////+goKD/R0dH/0BAQP9AQED/QEBA/0BAQP9AQED/SEhI/2VlZf+x + sbH//v7+//////////////////////////////////////////////////////////////////////// + /////v7+/62trf+wsLD///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////++ + vr7/MzMz/zMzM/+1tbX///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////++vr7/MzMz/zMzM/+1 + tbX///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////vr6+/zMzM/8zMzP/tbW1//////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////76+vv8zMzP/MzMz/7W1tf////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////+9vb3/MzMz/zc3N/alpaX///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////ra2t/zQ0NPo3 + NzeyWlpa//v7+/////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////f39/2FhYf83Nze8Pj4+MTQ0NPh1dXX/6+vr//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////7e3t/3t7e/80NDT6Pj4+OQAAAAA8PDxJNTU16jY2Nv9MTEz/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9Q + UFD/UFBQ/1BQUP9QUFD/UFBQ/1BQUP9QUFD/TU1N/zY2Nv81NTXtOzs7UAAAAAAAAAAAAAAAAEtLSw09 + PT1cNzc3gDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4Az + MzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDMzM4AzMzOAMzMzgDc3N4A+Pj5gSEhIEAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAP//////////wAAAAAAAAAOAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAHAAAAAAAAAA///////////KAAAADAAAABg + AAAAAQAgAAAAAAAAJAAAEwsAABMLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9PT0lNzc3kTMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68z + MzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzMzM68zMzOvMzMzrzY2NpQ8PDwoAAAAADs7OzM6 + OjrvkpKS/8DAwP/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/C + wsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/C + wsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wsLC/8LCwv/CwsL/wcHB/5WVlf87 + OzvxOzs7ODY2NsGtra3///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////+0tLT/NjY2yTs7O/zv7+////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////z8/P/Ozs7/jo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////01NTf9KSkr/SkpK/0pKSv9KSkr/SkpK/0pKSv9K + Skr/SkpK/11dXf/MzMz///////////////////////////////////////////////////////////// + ////////////////////m5ub/0pKSv9KSkr/SkpK/0pKSv9KSkr/SkpK/0pKSv9KSkr/Tk5O/5CQkP/8 + /Pz////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9ISEj/+vr6//////////////////////////////////////// + ////////////////////////////////////jo6O/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/+2trb////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////15eXv9b + W1v/W1tb/1tbW/9bW1v/W1tb/1tbW/9YWFj/NTU1/zMzM/80NDT/7+/v//////////////////////// + ////////////////////////////////////////////////////pKSk/1tbW/9bW1v/W1tb/1tbW/9b + W1v/W1tb/1tbW/9DQ0P/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + ////////////////////////////////////////////////////dHR0/zMzM/80NDT/7+/v//////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////MzMz/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT////////////////////////////6+vr/8/Pz//Pz8//z8/P/8/Pz//b29v//////gICA/zMzM/80 + NDT/7+/v/////////////Pz8//T09P/z8/P/8/Pz//Pz8//z8/P/8/Pz//Pz8//29vb///////////// + //////////7+/v/09PT/8/Pz//Pz8//z8/P/8/Pz//39/f/Z2dn/MzMz/zMzM/+YmJj////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////8HBwf9HR0f/NjY2/zY2Nv82Njb/NjY2/2dnZ/// + ////gICA/zMzM/80NDT/7+/v///////e3t7/VVVV/zY2Nv82Njb/NjY2/zY2Nv82Njb/NjY2/zY2Nv88 + PDz/kJCQ////////////8/Pz/2pqav84ODj/NjY2/zY2Nv82Njb/NjY2/9nZ2f/Z2dn/MzMz/zMzM/+Y + mJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////0pKSv8zMzP/MzMz/zMzM/8z + MzP/MzMz/2VlZf//////gICA/zMzM/80NDT/7+/v//////92dnb/MzMz/zMzM/8zMzP/MzMz/zMzM/8z + MzP/MzMz/zMzM/8zMzP/MzMz/9jY2P//////oqKi/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/9nZ2f/Z + 2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8z + MzP/NjY2/3V1df97e3v/e3t7/5ubm///////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/zMzM/9o + aGj/fHx8/3x8fP98fHz/e3t7/0ZGRv8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/WVlZ/3t7e/97 + e3v/e3t7/+bm5v/Z2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////////zY2Nv8zMzP/ampq////////////////////////////gICA/zMzM/80NDT/7+/v//////9i + YmL/MzMz/0FBQf/7+/v//////////////////////6ampv8zMzP/MzMz/8TExP//////jo6O/zMzM/8z + MzP/3d3d///////////////////////Z2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////////zY2Nv8zMzP/bm5u////////////////////////////gICA/zMzM/80 + NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7//////////////////////6urq/8zMzP/MzMz/8TExP// + ////jo6O/zMzM/8zMzP/4uLi///////////////////////Z2dn/MzMz/zMzM/+YmJj////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/bm5u//////////////////////// + ////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7//////////////////////6urq/8z + MzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/4uLi///////////////////////Z2dn/MzMz/zMzM/+Y + mJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8zMzP/bm5u//////// + ////////////////////gICA/zMzM/80NDT/7+/v//////9iYmL/MzMz/0NDQ//+/v7///////////// + /////////6urq/8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/4uLi///////////////////////Z + 2dn/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////zY2Nv8z + MzP/aGho////////////////////////////enp6/zMzM/80NDT/7+/v//////9iYmL/MzMz/0BAQP/7 + +/v//////////////////////6Wlpf8zMzP/MzMz/8TExP//////jo6O/zMzM/8zMzP/29vb//////// + ///////////////S0tL/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////////zY2Nv8zMzP/NDQ0/2lpaf9vb2//b29v/29vb/9ra2v/Nzc3/zMzM/80NDT/7+/v//////9i + YmL/MzMz/zMzM/9dXV3/b29v/29vb/9vb2//b29v/0FBQf8zMzP/MzMz/8TExP//////jo6O/zMzM/8z + MzP/UFBQ/29vb/9vb2//b29v/29vb/9OTk7/MzMz/zMzM/+YmJj////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////////01NTf8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/9D + Q0P/+Pj4//////95eXn/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/NDQ0/9vb2/// + ////paWl/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/zMzM/+wsLD////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////8nJyf9NTU3/Nzc3/zc3N/83Nzf/Nzc3/zc3N/83 + Nzf/Nzc3/0hISP+9vb3////////////j4+P/Xl5e/zc3N/83Nzf/Nzc3/zc3N/83Nzf/Nzc3/zc3N/8/ + Pz//m5ub////////////9vb2/3R0dP85OTn/Nzc3/zc3N/83Nzf/Nzc3/zc3N/83Nzf/OTk5/3t7e//4 + +Pj////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////////////////v7+//7+/v/+ + /v7//v7+//7+/v/+/v7//v7+//////////////////////////////////7+/v/+/v7//v7+//7+/v/+ + /v7//v7+//7+/v/////////////////////////////////+/v7//v7+//7+/v/+/v7//v7+//7+/v/+ + /v7//v7+///////////////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT///////////////////////7+/v/6+vr////////////////////////////5 + +fn///////////////////////7+/v/39/f//Pz8//////////////////////////////////////// + //////////7+/v/6+vr//////////////////////////////////v7+//b29v/9/f3///////////// + ///////////////4+Pj/PDw8/zo6Ov/09PT//////////////////////2xsbP9ERET/2dnZ//////// + /////////6ampv8+Pj7/lJSU///////6+vr/mJiY/01NTf88PDz/QUFB/3Z2dv/j4+P////////////g + 4OD/bm5u/1hYWP90dHT/6+vr/39/f/9ERET/3Nzc//////////////////v7+/+bm5v/TU1N/zs7O/9F + RUX/goKC/+/v7//////////////////4+Pj/PDw8/zo6Ov/09PT//////////////////f39/zQ0NP8z + MzP/ra2t////////////2tra/zg4OP8zMzP/ioqK//////9+fn7/MzMz/0ZGRv+Hh4f/ZmZm/zQ0NP9M + TEz/+Pj4//////+Dg4P/MzMz/29vb/+BgYH/5eXl/0tLS/8zMzP/tLS0/////////////////4GBgf8z + MzP/Pj4+/3BwcP9JSUn/MzMz/1lZWf/4+Pj////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////Pz8/zQ0NP8zMzP/ra2t///////7+/v/YGBg/zMzM/9AQED/5+fn/+Xl5f82Njb/MzMz/9bW1v// + /////v7+/6mpqf9vb2//9/f3//////9ycnL/MzMz/9fX1////////////0tLS/8zMzP/tLS0//////// + ////5ubm/zQ0NP80NDT/y8vL///////s7Oz/QUFB/zMzM/+5ubn////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////Pz8/zQ0NP8zMzP/ra2t//39/f+Pj4//MzMz/zU1Nf+4uLj//////8fHx/8z + MzP/MzMz/01NTf9OTk7/Tk5O/09PT/9wcHD/9fX1//////9xcXH/MzMz/9nZ2f///////////0tLS/8z + MzP/sLCw////////////w8PD/zMzM/9AQED/+fn5////////////Z2dn/zMzM/+VlZX////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8zMzP/RkZG/0ZGRv8zMzP/MzMz/2pqav/x + 8fH//////9LS0v8zMzP/MzMz/4uLi/+Tk5P/jo6O/zMzM/8zMzP/2NjY//////9xcXH/MzMz/9nZ2f// + /////////0tLS/8zMzP/mZmZ////////////z8/P/zMzM/84ODj/7u7u///////+/v7/V1dX/zMzM/+h + oaH////////////4+Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8zMzP/QEBA/0lJSf9G + Rkb/NjY2/zMzM/9NTU3/6enp//j4+P9LS0v/MzMz/4yMjP/f39//lZWV/zMzM/9FRUX/9vb2//b29v9q + amr/MzMz/8bGxv/5+fn//////0tLS/8zMzP/S0tL/6+vr/+SkpL/z8/P/0lJSf8zMzP/enp6/9PT0/+b + m5v/NDQ0/zg4OP/e3t7////////////4+Pj/PDw8/zo6Ov/09PT//////////////////Pz8/zQ0NP8z + MzP/rKys//39/f/8/Pz/4ODg/zw8PP8zMzP/nJyc///////Ly8v/QUFB/zMzM/8zMzP/MzMz/z09Pf/C + wsL//////3R0dP8zMzP/MzMz/zQ0NP+Ghob//////1VVVf8zMzP/PDw8/zMzM/8zMzP/jIyM/83Nzf9D + Q0P/MzMz/zMzM/8zMzP/ODg4/62trf/////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + /////Pz8/zQ0NP8zMzP/ra2t////////////6+vr/z09Pf8zMzP/kpKS////////////8PDw/7Kysv+b + m5v/ra2t/+zs7P////////////X19f9oaGj/MzMz/8LCwv/39/f//////9HR0f+oqKj/5eXl/6CgoP+z + s7P/+Pj4///////y8vL/tLS0/5ubm/+rq6v/5ubm///////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT//////////////////f39/zU1Nf8zMzP/RkZG/1NTU/9RUVH/PT09/zMzM/83Nzf/0tLS//////// + //////////////////////////////////////////////90dHT/MzMz/9vb2/////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zo6Ov/09PT//////////////////////3l5ef8+Pj7/PT09/z09Pf89PT3/QkJC/2FhYf/B + wcH////////////////////////////////////////////////////////////FxcX/i4uL//r6+v// + //////////////////////////////////////////////////////////////////////////////// + ///////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////4+Pj/PDw8/zo6Ov/09PT///////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////4+Pj/PDw8/zo6Ov/0 + 9PT///////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////4 + +Pj/PDw8/zs7O/7x8fH///////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////19fX/Ozs7/zY2NtC/v7////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////FxcX/NjY21zo6OkpDQ0P5srKy/+Dg4P/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h + 4eH/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/h4eH/4ODg/7W1tf9FRUX7OTk5UHFxcQA6OjpCNjY2uTQ0NN8z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98z + MzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfMzMz3zMzM98zMzPfNDQ03zc3N7w6OjpGa2trAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAP///////wAAgAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////8AACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAABMLAAAT + CwAAAAAAAAAAAAAAAAAARERECjMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAz + MzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAzMzMgMzMzIDMzMyAz + MzMgMzMzIDMzMyBDQ0MKAAAAADc3N0pjY2PwkpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+S + kpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+SkpL/kpKS/5KSkv+S + kpL/kpKS/5KSkv+SkpL/kpKS/2VlZfE3NzdOWVlZ4vv7+/////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////Pz8/1tbW+Z0dHT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////eHh4/3R0dP////////////////// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////94eHj/dHR0//////// + /////////4iIiP+Hh4f/h4eH/4eHh/+Hh4f/h4eH/6Kiov/9/f3///////////////////////////// + ////////////////////0tLS/4eHh/+Hh4f/h4eH/4eHh/+Hh4f/iIiI/8zMzP///////////3h4eP90 + dHT/////////////////NTU1/zMzM/8zMzP/MzMz/zMzM/8zMzP/MzMz/7+/v/////////////////// + //////////////////////////////+0tLT/MzMz/zMzM/8zMzP/MzMz/zMzM/8zMzP/QUFB//7+/v// + ////eHh4/3R0dP/////////////////W1tb/1tbW/9bW1v/W1tb/1tbW/4WFhf8zMzP/sbGx//////// + //////////////////////////////////////////Dw8P/W1tb/1tbW/9bW1v/W1tb/09PT/0pKSv81 + NTX//f39//////94eHj/dHR0/////////////////+zs7P+3t7f/tbW1/7W1tf/a2tr/qqqq/zMzM/+x + sbH//////9/f3/+1tbX/tbW1/7W1tf+1tbX/tbW1/97e3v///////////9HR0f+1tbX/tbW1/7W1tf/2 + 9vb/XV1d/zU1Nf/9/f3//////3h4eP90dHT/////////////////UlJS/zMzM/8zMzP/MzMz/5iYmP+q + qqr/MzMz/7Gxsf/v7+//PDw8/zMzM/8zMzP/MzMz/zMzM/8zMzP/Ozs7/+7u7v/Q0ND/NDQ0/zMzM/8z + MzP/MzMz/+Xl5f9dXV3/NTU1//39/f//////eHh4/3R0dP////////////////81NTX/QEBA/6SkpP+n + p6f/09PT/6qqqv8zMzP/sbGx/9ra2v8zMzP/U1NT/6enp/+np6f/p6en/1RUVP8zMzP/2NjY/7S0tP8z + MzP/aGho/6enp/+np6f/9PT0/11dXf81NTX//f39//////94eHj/dHR0/////////////////zU1Nf9b + W1v/////////////////qqqq/zMzM/+xsbH/2tra/zMzM/+BgYH/////////////////g4OD/zMzM//Y + 2Nj/tLS0/zMzM/+np6f/////////////////XV1d/zU1Nf/9/f3//////3h4eP90dHT///////////// + ////NTU1/1tbW/////////////////+qqqr/MzMz/7Gxsf/a2tr/MzMz/4GBgf////////////////+D + g4P/MzMz/9jY2P+0tLT/MzMz/6enp/////////////////9dXV3/NTU1//39/f//////eHh4/3R0dP// + //////////////81NTX/WFhY/////////////////6enp/8zMzP/sbGx/9ra2v8zMzP/fn5+//////// + /////////4CAgP8zMzP/2NjY/7S0tP8zMzP/paWl/////////////////1tbW/81NTX//f39//////94 + eHj/dHR0/////////////////zY2Nv80NDT/WVlZ/1tbW/9bW1v/QUFB/zMzM/+zs7P/3Nzc/zMzM/85 + OTn/W1tb/1tbW/9bW1v/OTk5/zMzM//Z2dn/tbW1/zMzM/9AQED/W1tb/1tbW/9ZWVn/NDQ0/zY2Nv/9 + /f3//////3h4eP90dHT/////////////////iIiI/zc3N/81NTX/NTU1/zU1Nf81NTX/UVFR/+np6f/7 + +/v/Z2dn/zY2Nv81NTX/NTU1/zU1Nf82Njb/ZmZm//r6+v/r6+v/UlJS/zU1Nf81NTX/NTU1/zU1Nf83 + Nzf/hoaG////////////eHh4/3R0dP////////////////////////////7+/v/+/v7//v7+//7+/v// + /////////////////////v7+//7+/v/+/v7//v7+//7+/v///////////////////////v7+//7+/v/+ + /v7//v7+//////////////////////94eHj/dHR0//////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////3h4eP90dHT///////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////eHh4/3R0dP////////////////+Q + kJD/xsbG////////////rKys/6Kiov//////z8/P/4KCgv97e3v/tra2////////////sbGx/5OTk//b + 29v/mZmZ/8jIyP///////////+3t7f+Tk5P/eHh4/6CgoP/4+Pj///////////94eHj/dHR0//////// + /////f39/zQ0NP+EhIT//////9LS0v81NTX/fX19/9fX1/83Nzf/g4OD/6Ghof9AQED/wMDA/+3t7f8z + MzP/m5ub/9fX1/9DQ0P/iYmJ///////9/f3/VlZW/1NTU/+bm5v/Q0ND/3l5ef///////////3h4eP90 + dHT////////////9/f3/NDQ0/4SEhP/y8vL/U1NT/0VFRf/q6ur/mZmZ/zMzM/+FhYX/iYmJ/3p6ev/Y + 2Nj/5OTk/zMzM//m5ub//////0NDQ/+Hh4f//////9zc3P8zMzP/ubm5//////+UlJT/Nzc3//39/f// + ////eHh4/3R0dP////////////39/f80NDT/PDw8/zs7O/8zMzP/c3Nz//Pz8/+lpaX/MzMz/6ampv+q + qqr/MzMz/6enp//k5OT/MzMz/+bm5v//////Q0ND/2tra//+/v7/4+Pj/zMzM/+kpKT//////39/f/9C + QkL//v7+//////94eHj/dHR0/////////////f39/zQ0NP9vb2//ycnJ/7Gxsf83Nzf/hoaG//Dw8P9R + UVH/S0tL/0xMTP9NTU3/7Ozs/4SEhP8zMzP/ZmZm//X19f9HR0f/Nzc3/z4+Pv91dXX/iIiI/zc3N/9W + Vlb/NTU1/6ysrP///////////3h4eP90dHT////////////9/f3/NDQ0/3Fxcf/Q0ND/vLy8/zg4OP97 + e3v///////j4+P/Hx8f/xMTE//b29v//////3Nzc/zMzM//Y2Nj//////9TU1P/j4+P/wcHB/+7u7v// + ////2NjY/76+vv/k5OT/////////////////eHh4/3R0dP////////////////9VVVX/OTk5/zk5Of88 + PDz/X19f/+Dg4P/////////////////////////////////19fX/cXFx//X19f////////////////// + //////////////////////////////////////////////94eHj/dHR0//////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////3h4eP90dHT///////////// + //////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////eHh4/3R0dP// + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////94 + eHj/X19f6v7+/v////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + /////v7+/2FhYe03NzdcdHR0+qampv+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+n + p6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+np6f/p6en/6enp/+n + p6f/p6en/6enp/92dnb7Nzc3YQAAAAA+Pj4aNTU1QDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0AzMzNAMzMzQDMzM0Az + MzNAMzMzQDMzM0AzMzNANTU1QEBAQBwAAAAAgAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAEoAAAAEAAAACAAAAABACAAAAAAAAAEAAAT + CwAAEwsAAAAAAAAAAAAAWFhYUYeHh4+IiIiPiIiIj4iIiI+IiIiPiIiIj4iIiI+IiIiPiIiIj4iIiI+I + iIiPiIiIj4iIiI+IiIiPWVlZUrS0tPj///////////////////////////////////////////////// + /////////////////////////7a2tvm6urr//////8PDw//Dw8P/w8PD/+fn5/////////////////// + ////9PT0/8PDw//Dw8P/w8PD//Ly8v+8vLz/urq6//////+FhYX/hISE/3BwcP91dXX///////////// + /////////+jo6P+EhIT/hISE/2FhYf+cnJz/vLy8/7q6uv//////ioqK/3R0dP+ysrL/cnJy/8LCwv90 + dHT/dHR0/4CAgP/v7+//e3t7/3R0dP+lpaX/mZmZ/7y8vP+6urr//////0FBQf/S0tL/ycnJ/3Jycv+H + h4f/n5+f/9PT0/9PT0//xsbG/11dXf/T09P/q6ur/5mZmf+8vLz/urq6//////9HR0f//////9TU1P9y + cnL/h4eH/7+/v///////Wlpa/8bGxv9tbW3//////62trf+ZmZn/vLy8/7q6uv//////SkpK/0hISP9C + QkL/iIiI/5ycnP9AQED/SEhI/0JCQv/d3d3/Pz8//0hISP8+Pj7/rq6u/7y8vP+6urr///////////// + //////////////////////////////////////////////////////////////+8vLz/urq6///////V + 1dX//////9PT0//z8/P/v7+//+3t7f/s7Oz/29vb/9jY2P//////4ODg/8XFxf/9/f3/vLy8/7q6uv/+ + /v7/XFxc/8XFxf94eHj/dnZ2/4yMjP+VlZX/jo6O/9bW1v9mZmb/9vb2/2VlZf+cnJz/q6ur/7y8vP+6 + urr//v7+/0RERP96enr/iYmJ/4aGhv96enr/hYWF/3R0dP/Q0ND/S0tL/6Wlpf9mZmb/goKC/7u7u/+8 + vLz/urq6//7+/v9NTU3/gICA/3x8fP/9/f3/4uLi//39/f+dnZ3/8/Pz/+3t7f/r6+v/9fX1/+jo6P// + ////vLy8/7q6uv////////////////////////////////////////////////////////////////// + /////////7y8vP+2trb6//////////////////////////////////////////////////////////// + //////////////+3t7f7YWFhXJCQkJ+QkJCfkJCQn5CQkJ+QkJCfkJCQn5CQkJ+QkJCfkJCQn5CQkJ+Q + kJCfkJCQn5CQkJ+QkJCfYmJiXgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + + + \ No newline at end of file diff --git a/src/RetroGOG/retroGOG.ico b/src/RetroGOG/retroGOG.ico new file mode 100644 index 0000000000000000000000000000000000000000..d251c327e41c79b6f6b23dc5e531aeeae916a6a4 GIT binary patch literal 105803 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 Date: Fri, 28 Aug 2020 16:15:56 -0700 Subject: [PATCH 28/37] FIX: issue with selectbox Thanks JHorbach --- RetroGOG.exe | Bin 992256 -> 992256 bytes src/RetroGOG/frmPluginSelect.cs | 70 ++++++++++++++++---------------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/RetroGOG.exe b/RetroGOG.exe index b6644490928e48010b90e6b9e4d5335e47e165be..ea230d64422088178bf1336daafbdbd5ec492847 100644 GIT binary patch delta 467 zcmZoTVbgHJWoyn1m%NY+$W@HlPt?^}G zU}j)quwr0fTe4Y^X`U|Qtj!1Xd$_G)gcul<>lheL0O>ozKz49RQD$CxF#`}RQDb0m zQ3Q&G1~a^HXJ8NjiYdE?db$AVAMTUethE?RH*d6l%(QudodgeK;^duPnv9*3AA4y^ z&SXyI{mH<{c#iQt(?Vue{%1h4Lhu#S!pW-M%8V;FdwO3s7l=HiRn55jzT=ep2R$w= zynA4?PV+7%or8RA49t8$z{q~YfS41Axqz5^yF(k#mM@|K(ZVx99%TaJ z0w9)|$;%)!owJ%(*>j7Zp=rU%G|#=ty-HlZJFhYVrJb!}LW@(2ies{hGZK?y{PR*> ziZaVmV_fo+OLJ56N{VCLGfO;5lVXBWON#Q{{oP}Vi;|~j0F4vc0yG&0&Vkqp6BWf6 V4@}=u&8yjdubOxJy=p$C6#yw0jNAYK delta 444 zcmZoTVbgHJWlhfW0O>ozKz49RQD$CxF#`~6P-9?l zQ3Q&G1~YtdXJ8NjiYdE?db$8<7LUno)>@3^n>SiNX5!&ukQYA1f0)sYn}K2T6gvqX z#?HxWy)+qTPQLD?DY=q4mG>tDBjY*7`%D{|S^1v<$qK<&OdBUldMh*T+-&Q8*<4`N z--ti2ctm|BIMsVTV6SuCtkArRiSgv*+pRME7ugt?82M!R*c2EUg&MC;W^2=8xhvFo zx7nnv-K32Xh?#(x8Hibcm=%cGfS4VKIe?fGh`E56d%H;+&z3Kup>wXx2YHVPh?9X> zV Date: Wed, 2 Sep 2020 14:45:09 -0700 Subject: [PATCH 29/37] FIX: apostrophe parsing issue Refactored code so that game_ids would always be evaluated the same way, using the new format_game() function. --- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- .../plugin.py | 14 ++++++++++---- 22 files changed, 220 insertions(+), 88 deletions(-) diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py index df4bc4a..ef55352 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py index 16c19c9..03cb4a9 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py index 408822f..4eb5237 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py index f1e2426..adec4ec 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py index 272d8a4..a28ac32 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py index 3582134..5338d02 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py index 7d86534..cbeacd4 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py index 76bdef2..981cb3d 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py index a1c4de1..54b9fbb 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/plugin.py @@ -40,6 +40,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -51,7 +57,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -98,9 +104,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -114,7 +120,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py index aaf736f..cbeb22a 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py index ec97c1c..b59370a 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py index 716bbba..b01a6d2 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py index 913d651..221d464 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py index 3ed0819..078f6d2 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py index ef9d30a..6816265 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py index d20a3fa..c544aa3 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py index ff5137d..921fee2 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py index 57f310a..84f5f8a 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py index 59c3aba..3dc60e7 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py index 7fe6f19..4deeda6 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py index c13d939..c2ebc7e 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py index 27d4b49..c0abb81 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/plugin.py @@ -39,6 +39,12 @@ async def get_owned_games(self): self.update_game_cache() return self.game_cache + # Format helper for game names + def format_game(self, game): + game_return = game.rsplit(" (")[0] + game_return = game_return.replace("'","") + return game_return + #Scans retroarch playlist for roms in rom_path and adds them to self.game_cache #as roms don't need to be installed, owned games and local games are the same and both run update_game_cache def update_game_cache(self): @@ -50,7 +56,7 @@ def update_game_cache(self): for entry in playlist_dict["items"]: rom_path = entry["path"].split("#")[0] if os.path.isfile(rom_path): - provided_name = entry["label"].split(" (")[0] + provided_name = self.format_game(entry["label"]) game_list.append( Game( provided_name, @@ -97,9 +103,9 @@ async def launch_game(self, game_id): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for entry in playlist_dict["items"]: - if game_id == entry["label"].split(" (")[0]: + if game_id == self.format_game(entry["label"]): self.update_local_game_status(LocalGame(game_id, 2)) - self.game_run = entry["label"].split(" (")[0] + self.game_run = self.format_game(entry["label"]) self.proc = subprocess.Popen(os.path.abspath(user_config.emu_path + "retroarch.exe" + " -L \"" + user_config.emu_path + "cores/" + user_config.core + "\" \"" + entry["path"])) break @@ -113,7 +119,7 @@ async def get_game_time(self, game_id: str, context:any): with open(self.playlist_path) as playlist_json: playlist_dict = json.load(playlist_json) for rom in playlist_dict["items"]: - if game_id == rom["label"].split(" (")[0]: + if game_id == self.format_game(rom["label"]): file_path = user_config.emu_path + "/playlists/logs/" + rom["path"].rsplit("\\",1)[1].rsplit("#")[0].rsplit(".",1)[0] + ".lrtl" if os.path.isfile(file_path): with open(file_path) as json_data: From 5d4665f0bf8e5f6a06a257d7bef2139dac67511c Mon Sep 17 00:00:00 2001 From: jshackles Date: Wed, 2 Sep 2020 15:32:43 -0700 Subject: [PATCH 30/37] ADD: program indexes for GBC and GBA --- src/RetroGOG/frmPluginSelect.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/RetroGOG/frmPluginSelect.cs b/src/RetroGOG/frmPluginSelect.cs index 1e134a4..f56278b 100644 --- a/src/RetroGOG/frmPluginSelect.cs +++ b/src/RetroGOG/frmPluginSelect.cs @@ -105,6 +105,8 @@ private void btnNext_Click(object sender, EventArgs e) plugin_codes.Add("Atari - 2600", "atari_830528d9-e621-48e9-8ed4-e03a4853843e"); plugin_codes.Add("Sega - Dreamcast", "dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8"); plugin_codes.Add("Nintendo - Game Boy", "gb_4345afe1-a2c3-4c58-93d3-373c53a90a92"); + plugin_codes.Add("Nintendo - Game Boy Advance", "gba_16a78ef5-fba6-4629-b83c-ef47adab5aab"); + plugin_codes.Add("Nintendo - Game Boy Color", "gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a"); plugin_codes.Add("Atari - Jaguar", "jaguar_b9773549-9c20-4729-b23d-f683762ce73a"); plugin_codes.Add("Nintendo - Nintendo 64", "n64_a3824d31-c2d3-4a1a-b321-7d0764da5513"); plugin_codes.Add("Nintendo - GameCube", "ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7"); @@ -127,6 +129,8 @@ private void btnNext_Click(object sender, EventArgs e) core_codes.Add("Atari - 2600", "stella_libretro.dll"); core_codes.Add("Sega - Dreamcast", "flycast_libretro.dll"); core_codes.Add("Nintendo - Game Boy", "mgba_libretro.dll"); + core_codes.Add("Nintendo - Game Boy Advance", "mgba_libretro.dll"); + core_codes.Add("Nintendo - Game Boy Color", "mgba_libretro.dll"); core_codes.Add("Atari - Jaguar", "virtualjaguar_libretro.dll"); core_codes.Add("Nintendo - Nintendo 64", "mupen64plus_next_libretro.dll"); core_codes.Add("Nintendo - GameCube", "dolphin_libretro.dll"); From 66a8ee03fb5cf7c62b19e12a3f69c4d74428c0aa Mon Sep 17 00:00:00 2001 From: jshackles Date: Wed, 2 Sep 2020 15:33:37 -0700 Subject: [PATCH 31/37] FIX: selecting non-playlist files --- src/RetroGOG/frmPluginSelect.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/RetroGOG/frmPluginSelect.cs b/src/RetroGOG/frmPluginSelect.cs index f56278b..8de41a7 100644 --- a/src/RetroGOG/frmPluginSelect.cs +++ b/src/RetroGOG/frmPluginSelect.cs @@ -45,7 +45,10 @@ private void frmPluginSelect_Load(object sender, EventArgs e) { foreach (string name in strPlaylists) { - chbPlaylists.Items.Add(name.Replace(playlistpath, "").Replace(".lpl", ""), true); + if (name.Substring(Math.Max(0, name.Length - 4)) == ".lpl") + { + chbPlaylists.Items.Add(name.Replace(playlistpath, "").Replace(".lpl", ""), true); + } } } else From 962bc745a0fa909fb5971f5f3f3c399c50fb2ae1 Mon Sep 17 00:00:00 2001 From: jshackles Date: Wed, 2 Sep 2020 16:14:43 -0700 Subject: [PATCH 32/37] FIX: playlist iteration issues Playlist files will now be matched to avaialble entries in the plugin_codes dictionary. --- RetroGOG.exe | Bin 992256 -> 993280 bytes src/RetroGOG/frmPluginSelect.cs | 173 +++++++++++++++++--------------- 2 files changed, 90 insertions(+), 83 deletions(-) diff --git a/RetroGOG.exe b/RetroGOG.exe index ea230d64422088178bf1336daafbdbd5ec492847..c7b72e950c78882043276567e032f7173d34ff35 100644 GIT binary patch delta 9265 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 diff --git a/src/RetroGOG/frmPluginSelect.cs b/src/RetroGOG/frmPluginSelect.cs index 8de41a7..d91b16d 100644 --- a/src/RetroGOG/frmPluginSelect.cs +++ b/src/RetroGOG/frmPluginSelect.cs @@ -15,6 +15,9 @@ namespace RetroGOG { public partial class frmPluginSelect : Form { + IDictionary plugin_codes = new Dictionary(); + IDictionary core_codes = new Dictionary(); + public frmPluginSelect() { InitializeComponent(); @@ -38,6 +41,53 @@ private void btnBack_Click(object sender, EventArgs e) private void frmPluginSelect_Load(object sender, EventArgs e) { + // Build dictionaries + plugin_codes.Add("The 3DO Company - 3DO", "3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577"); + plugin_codes.Add("Nintendo - Nintendo 3DS", "3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55"); + plugin_codes.Add("Atari - 2600", "atari_830528d9-e621-48e9-8ed4-e03a4853843e"); + plugin_codes.Add("Sega - Dreamcast", "dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8"); + plugin_codes.Add("Nintendo - Game Boy", "gb_4345afe1-a2c3-4c58-93d3-373c53a90a92"); + plugin_codes.Add("Nintendo - Game Boy Advance", "gba_16a78ef5-fba6-4629-b83c-ef47adab5aab"); + plugin_codes.Add("Nintendo - Game Boy Color", "gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a"); + plugin_codes.Add("Atari - Jaguar", "jaguar_b9773549-9c20-4729-b23d-f683762ce73a"); + plugin_codes.Add("Nintendo - Nintendo 64", "n64_a3824d31-c2d3-4a1a-b321-7d0764da5513"); + plugin_codes.Add("Nintendo - GameCube", "ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7"); + plugin_codes.Add("Nintendo - Nintendo DS", "nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc"); + plugin_codes.Add("Nintendo - Nintendo Entertainment System", "nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad"); + plugin_codes.Add("Nintendo - Wii", "nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba"); + plugin_codes.Add("NEC - PC Engine - TurboGrafx 16", "pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a"); + plugin_codes.Add("Sony - PlayStation", "ps1_ff02c67d-5962-4e79-a3a3-928814edb270"); + plugin_codes.Add("Sony - PlayStation 2", "ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea"); + plugin_codes.Add("Sony - PlayStation Portable", "psp_05487532-ba29-411b-b799-784262d275bd"); + plugin_codes.Add("Sega - Saturn", "saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af"); + plugin_codes.Add("Sega - Mega-CD - Sega CD", "segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2"); + plugin_codes.Add("Sega - Mega Drive - Genesis", "segag_e3ac94e7-945e-459d-bc1e-676cff8173f9"); + plugin_codes.Add("Sega - Master System - Mark III", "sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b"); + plugin_codes.Add("Nintendo - Super Nintendo Entertainment System", "snes_bc831044-f772-4391-8c22-529f42cb9799"); + + core_codes.Add("The 3DO Company - 3DO", "opera_libretro.dll"); + core_codes.Add("Nintendo - Nintendo 3DS", "citra_libretro.dll"); + core_codes.Add("Atari - 2600", "stella_libretro.dll"); + core_codes.Add("Sega - Dreamcast", "flycast_libretro.dll"); + core_codes.Add("Nintendo - Game Boy", "mgba_libretro.dll"); + core_codes.Add("Nintendo - Game Boy Advance", "mgba_libretro.dll"); + core_codes.Add("Nintendo - Game Boy Color", "mgba_libretro.dll"); + core_codes.Add("Atari - Jaguar", "virtualjaguar_libretro.dll"); + core_codes.Add("Nintendo - Nintendo 64", "mupen64plus_next_libretro.dll"); + core_codes.Add("Nintendo - GameCube", "dolphin_libretro.dll"); + core_codes.Add("Nintendo - Nintendo DS", "desmume_libretro.dll"); + core_codes.Add("Nintendo - Nintendo Entertainment System", "mesen_libretro.dll"); + core_codes.Add("Nintendo - Wii", "dolphin_libretro.dll"); + core_codes.Add("NEC - PC Engine - TurboGrafx 16", "mednafen_pce_fast_libretro.dll"); + core_codes.Add("Sony - PlayStation", "pcsx_rearmed_libretro.dll"); + core_codes.Add("Sony - PlayStation 2", "play_libretro.dll"); + core_codes.Add("Sony - PlayStation Portable", "ppsspp_libretro.dll"); + core_codes.Add("Sega - Saturn", "mednafen_saturn_libretro.dll"); + core_codes.Add("Sega - Mega-CD - Sega CD", "genesis_plus_gx_libretro.dll"); + core_codes.Add("Sega - Mega Drive - Genesis", "genesis_plus_gx_libretro.dll"); + core_codes.Add("Sega - Master System - Mark III", "genesis_plus_gx_libretro.dll"); + core_codes.Add("Nintendo - Super Nintendo Entertainment System", "snes9x_libretro.dll"); + tmrCheck.Enabled = true; string playlistpath = Globals.RAPath.Replace("retroarch.exe", "playlists\\"); string[] strPlaylists = Directory.GetFiles(playlistpath); @@ -47,7 +97,11 @@ private void frmPluginSelect_Load(object sender, EventArgs e) { if (name.Substring(Math.Max(0, name.Length - 4)) == ".lpl") { - chbPlaylists.Items.Add(name.Replace(playlistpath, "").Replace(".lpl", ""), true); + string playlist_name = name.Replace(playlistpath, "").Replace(".lpl", ""); + if (plugin_codes.ContainsKey(playlist_name)) + { + chbPlaylists.Items.Add(playlist_name, true); + } } } } @@ -101,56 +155,6 @@ private void btnNext_Click(object sender, EventArgs e) barProgress.Value = 0; barProgress.Maximum = chbPlaylists.CheckedItems.Count; - // Build dictionaries - IDictionary plugin_codes = new Dictionary(); - plugin_codes.Add("The 3DO Company - 3DO", "3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577"); - plugin_codes.Add("Nintendo - Nintendo 3DS", "3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55"); - plugin_codes.Add("Atari - 2600", "atari_830528d9-e621-48e9-8ed4-e03a4853843e"); - plugin_codes.Add("Sega - Dreamcast", "dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8"); - plugin_codes.Add("Nintendo - Game Boy", "gb_4345afe1-a2c3-4c58-93d3-373c53a90a92"); - plugin_codes.Add("Nintendo - Game Boy Advance", "gba_16a78ef5-fba6-4629-b83c-ef47adab5aab"); - plugin_codes.Add("Nintendo - Game Boy Color", "gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a"); - plugin_codes.Add("Atari - Jaguar", "jaguar_b9773549-9c20-4729-b23d-f683762ce73a"); - plugin_codes.Add("Nintendo - Nintendo 64", "n64_a3824d31-c2d3-4a1a-b321-7d0764da5513"); - plugin_codes.Add("Nintendo - GameCube", "ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7"); - plugin_codes.Add("Nintendo - Nintendo DS", "nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc"); - plugin_codes.Add("Nintendo - Nintendo Entertainment System", "nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad"); - plugin_codes.Add("Nintendo - Wii", "nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba"); - plugin_codes.Add("NEC - PC Engine - TurboGrafx 16", "pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a"); - plugin_codes.Add("Sony - PlayStation", "ps1_ff02c67d-5962-4e79-a3a3-928814edb270"); - plugin_codes.Add("Sony - PlayStation 2", "ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea"); - plugin_codes.Add("Sony - PlayStation Portable", "psp_05487532-ba29-411b-b799-784262d275bd"); - plugin_codes.Add("Sega - Saturn", "saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af"); - plugin_codes.Add("Sega - Mega-CD - Sega CD", "segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2"); - plugin_codes.Add("Sega - Mega Drive - Genesis", "segag_e3ac94e7-945e-459d-bc1e-676cff8173f9"); - plugin_codes.Add("Sega - Master System - Mark III", "sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b"); - plugin_codes.Add("Nintendo - Super Nintendo Entertainment System", "snes_bc831044-f772-4391-8c22-529f42cb9799"); - - IDictionary core_codes = new Dictionary(); - core_codes.Add("The 3DO Company - 3DO", "opera_libretro.dll"); - core_codes.Add("Nintendo - Nintendo 3DS", "citra_libretro.dll"); - core_codes.Add("Atari - 2600", "stella_libretro.dll"); - core_codes.Add("Sega - Dreamcast", "flycast_libretro.dll"); - core_codes.Add("Nintendo - Game Boy", "mgba_libretro.dll"); - core_codes.Add("Nintendo - Game Boy Advance", "mgba_libretro.dll"); - core_codes.Add("Nintendo - Game Boy Color", "mgba_libretro.dll"); - core_codes.Add("Atari - Jaguar", "virtualjaguar_libretro.dll"); - core_codes.Add("Nintendo - Nintendo 64", "mupen64plus_next_libretro.dll"); - core_codes.Add("Nintendo - GameCube", "dolphin_libretro.dll"); - core_codes.Add("Nintendo - Nintendo DS", "desmume_libretro.dll"); - core_codes.Add("Nintendo - Nintendo Entertainment System", "mesen_libretro.dll"); - core_codes.Add("Nintendo - Wii", "dolphin_libretro.dll"); - core_codes.Add("NEC - PC Engine - TurboGrafx 16", "mednafen_pce_fast_libretro.dll"); - core_codes.Add("Sony - PlayStation", "pcsx_rearmed_libretro.dll"); - core_codes.Add("Sony - PlayStation 2", "play_libretro.dll"); - core_codes.Add("Sony - PlayStation Portable", "ppsspp_libretro.dll"); - core_codes.Add("Sega - Saturn", "mednafen_saturn_libretro.dll"); - core_codes.Add("Sega - Mega-CD - Sega CD", "genesis_plus_gx_libretro.dll"); - core_codes.Add("Sega - Mega Drive - Genesis", "genesis_plus_gx_libretro.dll"); - core_codes.Add("Sega - Master System - Mark III", "genesis_plus_gx_libretro.dll"); - core_codes.Add("Nintendo - Super Nintendo Entertainment System", "snes9x_libretro.dll"); - - // Download latest galaxy API for plugins using (var client = new WebClient()) { @@ -160,42 +164,45 @@ private void btnNext_Click(object sender, EventArgs e) foreach (string item in chbPlaylists.CheckedItems) { - // Create Plugin directory - Directory.CreateDirectory(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\"); - - using (var client = new WebClient()) + if (plugin_codes.ContainsKey(item)) { - // Download Plugin from Github - client.Headers.Add("user-agent", "RetroGOG"); - client.DownloadFile("https://raw.githubusercontent.com/jshackles/RetroGOG/master/plugins/" + plugin_codes[item] + "/manifest.json", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "manifest.json"); - client.DownloadFile("https://raw.githubusercontent.com/jshackles/RetroGOG/master/plugins/" + plugin_codes[item] + "/plugin.py", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "plugin.py"); - client.DownloadFile("https://raw.githubusercontent.com/jshackles/RetroGOG/master/plugins/" + plugin_codes[item] + "/version.py", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "version.py"); - - // Download Galaxy API and decompress - if (Directory.Exists(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\galaxy\\")) - { - Directory.Delete(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\galaxy\\", true); - } - System.IO.Compression.ZipFile.ExtractToDirectory(Globals.GOGPluginPath + "\\galaxy_api.zip", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\galaxy\\"); + // Create Plugin directory + Directory.CreateDirectory(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\"); - // Write user_config.py file - string[] lines = new string[3]; - lines[0] = "# This file automatically generated by RetroGOG"; - lines[1] = "emu_path = \"" + Globals.RAPath.Replace("\\", "/").Replace("retroarch.exe", "") + "\""; - if (File.Exists(Globals.RAPath.Replace("retroarch.exe", "cores\\") + core_codes[item])) + using (var client = new WebClient()) { - lines[2] = "core = \"" + core_codes[item] + "\""; + // Download Plugin from Github + client.Headers.Add("user-agent", "RetroGOG"); + client.DownloadFile("https://raw.githubusercontent.com/jshackles/RetroGOG/master/plugins/" + plugin_codes[item] + "/manifest.json", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "manifest.json"); + client.DownloadFile("https://raw.githubusercontent.com/jshackles/RetroGOG/master/plugins/" + plugin_codes[item] + "/plugin.py", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "plugin.py"); + client.DownloadFile("https://raw.githubusercontent.com/jshackles/RetroGOG/master/plugins/" + plugin_codes[item] + "/version.py", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "version.py"); + + // Download Galaxy API and decompress + if (Directory.Exists(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\galaxy\\")) + { + Directory.Delete(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\galaxy\\", true); + } + System.IO.Compression.ZipFile.ExtractToDirectory(Globals.GOGPluginPath + "\\galaxy_api.zip", Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\galaxy\\"); + + // Write user_config.py file + string[] lines = new string[3]; + lines[0] = "# This file automatically generated by RetroGOG"; + lines[1] = "emu_path = \"" + Globals.RAPath.Replace("\\", "/").Replace("retroarch.exe", "") + "\""; + if (File.Exists(Globals.RAPath.Replace("retroarch.exe", "cores\\") + core_codes[item])) + { + lines[2] = "core = \"" + core_codes[item] + "\""; + } + else + { + Form frmCoreSelect = new frmCoreSelect(); + frmCoreSelect.Text = item; + frmCoreSelect.ShowDialog(this); + lines[2] = "core = \"" + Globals.TempCore + "\""; + } + System.IO.File.WriteAllLines(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "user_config.py", lines); + + barProgress.Value = barProgress.Value + 1; } - else - { - Form frmCoreSelect = new frmCoreSelect(); - frmCoreSelect.Text = item; - frmCoreSelect.ShowDialog(this); - lines[2] = "core = \"" + Globals.TempCore + "\""; - } - System.IO.File.WriteAllLines(Globals.GOGPluginPath + "\\" + plugin_codes[item] + "\\" + "user_config.py", lines); - - barProgress.Value = barProgress.Value + 1; } } } From 512a566ae1ef223bc6f11d4e393737245c70abca Mon Sep 17 00:00:00 2001 From: jshackles Date: Wed, 2 Sep 2020 16:21:45 -0700 Subject: [PATCH 33/37] BUILD: 0.4 --- plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json | 2 +- plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py | 2 +- plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json | 2 +- plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py | 2 +- .../atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json | 2 +- plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py | 2 +- plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json | 2 +- plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py | 2 +- plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json | 2 +- plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py | 2 +- plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json | 2 +- plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py | 2 +- plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json | 2 +- plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py | 2 +- .../jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json | 2 +- plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py | 2 +- plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json | 2 +- plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py | 2 +- .../ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json | 2 +- plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py | 2 +- plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json | 2 +- plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py | 2 +- plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json | 2 +- plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py | 2 +- plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json | 2 +- plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py | 2 +- plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json | 2 +- plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py | 2 +- plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json | 2 +- plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py | 2 +- plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json | 2 +- plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py | 2 +- plugins/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json | 2 +- plugins/psp_05487532-ba29-411b-b799-784262d275bd/version.py | 2 +- .../saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json | 2 +- plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py | 2 +- .../segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json | 2 +- plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py | 2 +- .../segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json | 2 +- plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py | 2 +- plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json | 2 +- plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py | 2 +- plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json | 2 +- plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py | 2 +- 44 files changed, 44 insertions(+), 44 deletions(-) diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json index 75dbaa6..2d95c9b 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy 3DO RetroArch plugin", "platform": "3do", "guid": "9d81c0ec-5646-4b1a-b809-e7e61e1d3577", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add 3DO games and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py index cce384d..58d168b 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json index 078e5c0..ca58ddd 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo 3DS RetroArch plugin", "platform": "3ds", "guid": "f6acd3ed-2c31-47d6-bae4-07b6714c1e55", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Nintendo 3DS roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py index cce384d..58d168b 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json index 485da37..3329195 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Atari 2600 RetroArch plugin", "platform": "atari", "guid": "830528d9-e621-48e9-8ed4-e03a4853843e", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Atari 2600 roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py index cce384d..58d168b 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json index 632c6ce..b302e80 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Sega Dreamcast RetroArch plugin", "platform": "dc", "guid": "5d181ffd-48dc-4330-aa58-6f646e76a5c8", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Sega Dreamcast isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py index cce384d..58d168b 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json index 0e3d38c..d352acb 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo Gameboy RetroArch plugin", "platform": "ngameboy", "guid": "4345afe1-a2c3-4c58-93d3-373c53a90a92", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Nintendo GameBoy roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py index cce384d..58d168b 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json index b5c3b85..9df536b 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo Gameboy Advance RetroArch plugin", "platform": "ngameboy", "guid": "16a78ef5-fba6-4629-b83c-ef47adab5aab", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Nintendo GameBoy Advance roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py index cce384d..58d168b 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json index a3cbb71..cb62617 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo Gameboy Color RetroArch plugin", "platform": "ngameboy", "guid": "9b53fc85-af7c-4ce2-af31-0d95234d783a", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Nintendo GameBoy Color roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py index cce384d..58d168b 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json index 25a5f60..fd934b6 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Atari Jaguar RetroArch plugin", "platform": "jaguar", "guid": "b9773549-9c20-4729-b23d-f683762ce73a", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Atari Jaguar roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py index cce384d..58d168b 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json index f3653cd..516e46b 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/manifest.json @@ -2,7 +2,7 @@ "name": "Galaxy n64 RetroArch plugin", "platform": "n64", "guid": "a3824d31-c2d3-4a1a-b321-7d0764da5513", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add n64 roms and start them with RetroArch emulator", "author": "riku55", "email": "", diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py index cce384d..58d168b 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json index 0842bc3..0eebe52 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo Gamecube RetroArch plugin", "platform": "ncube", "guid": "602422b9-ced5-476e-911a-7fa0adf0f7f7", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Nintendo Gamecube isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py index cce384d..58d168b 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json index 54b5b91..f875447 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo DS RetroArch plugin", "platform": "nds", "guid": "4704ed29-f516-4fd8-8477-ddbcdb7cedfc", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Nintendo DS roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py index cce384d..58d168b 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json index 25ff115..2029a8a 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy NES RetroArch plugin", "platform": "nes", "guid": "e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add NES roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py index cce384d..58d168b 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json index 36ea8e9..ddc1cf5 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Nintendo Wii RetroArch plugin", "platform": "nwii", "guid": "2d0e97ac-0406-4e5f-a85b-ab5b1a042cba", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Nintendo Wii isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py index cce384d..58d168b 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json index 65b57d0..03f6735 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy PC Engine RetroArch plugin", "platform": "pce", "guid": "c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add PC Engine games and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py index cce384d..58d168b 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json index ba635c4..6e081d7 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy PS1 RetroArch plugin", "platform": "psx", "guid": "ff02c67d-5962-4e79-a3a3-928814edb270", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add PS1 isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py index cce384d..58d168b 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json index ed06077..88289f5 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy PS2 RetroArch plugin", "platform": "ps2", "guid": "50ad79eb-393c-4f95-98ce-59f095ae47ea", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add PS2 isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py index cce384d..58d168b 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json index a2977a8..788fb6c 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy PSP RetroArch plugin", "platform": "psp", "guid": "05487532-ba29-411b-b799-784262d275bd", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add PSP isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/version.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/version.py index cce384d..58d168b 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/version.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json index bbf35ae..3014d4e 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Sega Saturn RetroArch plugin", "platform": "saturn", "guid": "bd6ec091-8ee0-440a-9e26-71bbf21c05af", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Sega Saturn isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py index cce384d..58d168b 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json index 2331196..51ee4ff 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Sega CD RetroArch plugin", "platform": "segacd", "guid": "ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Sega CD isos and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py index cce384d..58d168b 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json index 312c9b0..765050a 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Sega Genesis RetroArch plugin", "platform": "segag", "guid": "e3ac94e7-945e-459d-bc1e-676cff8173f9", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Sega Genesis roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py index cce384d..58d168b 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json index 20a53e2..ff8ab1b 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy Sega Master System RetroArch plugin", "platform": "sms", "guid": "c6689bfb-7ba4-4d24-98e3-bd2dc339926b", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add Sega Master System roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py index cce384d..58d168b 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json index b0ea0c8..3f93137 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/manifest.json @@ -2,7 +2,7 @@ "name": "GOG Galaxy SNES RetroArch plugin", "platform": "snes", "guid": "bc831044-f772-4391-8c22-529f42cb9799", - "version": "0.3", + "version": "0.4", "description": "Galaxy Plugin to add SNES roms and start them with the RetroArch emulator", "author": "jshackles", "email": "jshackles@gmail.com", diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py index cce384d..58d168b 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/version.py @@ -1 +1 @@ -__version__ = '0.3' +__version__ = '0.4' From 8e39e24c73c9d64706cbc793793a06a6e5c3f894 Mon Sep 17 00:00:00 2001 From: Jason Shackles Date: Sun, 3 Jan 2021 06:31:05 -0800 Subject: [PATCH 34/37] REMOVE: RetroGOG.exe Some people were having issues with the executable, it was removed. Build from source. --- README.md | 2 +- RetroGOG.exe | Bin 993280 -> 0 bytes 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 RetroGOG.exe diff --git a/README.md b/README.md index 56b38b9..2976f95 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in 4. Navigate to *Settings*, click on *Saving*, go to the last option there *Save runtime log (aggregate)* and turn it on. #### Setting up RetroGOG: -1. Download the [RetroGOG configuration wizard](RetroGOG.exe). +1. Download the repository and build RetroGOG.exe from the src folder using Visual Studio. 2. Run the application and follow the on-screen instructions. 3. (Re)start Galaxy 2.0 and connect the integration. diff --git a/RetroGOG.exe b/RetroGOG.exe deleted file mode 100644 index c7b72e950c78882043276567e032f7173d34ff35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 993280 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

$P{E-jlcXR-rSnt4i7JxpFX&i@d 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�WW}+B_(2tHe2ch~qfw*160ZXgqxU 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%0AwZ9*rn}6}*1>ySj>w|_RH#hg2 z?0-f61MdO?0tjdiDaEmg(wGJBVZQg}%a?EP{2u-%CMFW3q@;!fOJ8te-!^U9M1b|T zfrMADUJ*1kHHQp~zrX)C#r$gghq`_I`0*j*H{AS({jS)a28>_-#U1KwL`1|lydnJo z`)|d>#J-C*zdHY6&Eb3LJ6!F*p`l^Oeb7*FfVFSfXH70Zp9s2}q2!BC^Q-gU*x2~H zaU3rGU%YtnyJ5l8ejv|h&YU5~e!}zT&xZw;U!DKr;^Nu~4wf^(F^B?(>BEY&d>@Vo+B3-u} ziu~Un|6zUtdSxRcqXAuDbB6ZF_gSZg%75^+p{1qOCryA}q`p{wHTl1<_78j?ihUxq zr{U)R=FOW4wY9b6nrff#K&C|g(W^Rze5j$XwOmT}-+i_Jz`($vfgemB_$;Z zot>RSf+spUnj&3(HU7hTYj}7#AwNH#P*6}XperjYi-685qKG5VeJK0~8v{2tH=C9E6uWluO1{sRs$2h7OG7!XYWUZ7!AR8*h#gufd9u{JW?_o48AxM9HZ8!G>? z^uu}o8T^OyVullkf#gNW=59D_5eHH}K8}u#4%vPj)|>G0e#A4#;se>6KyEx6s!Rgs z2K)%z@MT0sMv|rZi4!M&I4p+`A0`XSy?gh5I4rQ|VIW~R`S0Q3@k8+h+nKt$I*U7C76BQoF#z1kc0Cf5;Ecl^PB^@c!Ds!Gj0; zoi{sCU2eLbL0F~B_5-roK{hXtMANAv|r5fG9O!_9xl9{K?o3ltR< ziF1M>J@SOA{kf(2+i z)bL-l|DnP{P50v|K;{3Br}?i`B9;HY5;uN4EL8sgc$)u8B~tnSD{5k7zZOd93?{f6}zI1>)+BEc@e z%F2phW@bk89dqi`DdKxd{`vl2W+X3Qod(v`ii(Pe7vw_;ud=c-!pV~-iLi%+giwq} zQUt}Hm>*rRJq7zBb93{rIKjCRH8nL9(+=draPlAgppx3hg5RIPtd)VCIB6cRb_+gy zDTRj*9};%%+}ZD0A&@WFgg_k~NC0~d@ZAMA+NAafVC!gNVnQe`F8-PooJ|Sq!uYsQ z2M2>cmf734Z;8HCg@uK`$q%15JOfU!CZ3a%gC!l*eK`5AqM||;)`9N*02^yTK|!*4 zkn@oQ{-mHykkX{T-(dgk=;+vIodO~b#Fs7Dx?p~-JUup}4Z{AQEZqk-V$ z?(R-pQyxrV!^MASE0lP53%-Lr4y6G8 z!N9(-zkC4aj~_o0vw?jjR!k&z*Q{Ui8GguA@FJjJ{zjRj!KNeU0(2V?vB^XG|m2gWwMPd*r% zNz=e@u=yEC03Xp{w@)q)cn5tAoc$y%E#2o>2Y&7c8h!=-gIu+0)hdd3lloVOwn>Rj zr%#{$e|CW&zkysx&VQqeiwjv&f!s;T2e3KpD|`?Cq0PcLPFg1X2D>*n`x>(^g}5(Y zz9gursgaEje#WsrsV{ip+t21d;Iw1M4zhRv?jZY-wtYW8KeF**A5~v{AB@AKeF2>D zKrWCg18jKv3g5$j*vkoW2`T@f&!HsS0gs_CAWZ|mVeU1M@U!{fm!EFv6QG`x21+)) zFmLQHG&MC5?K6Y6bLY+-0-X2J*9BuS>?;Ldu6>2!ux{D*et?CeZ9dh{q!|3+z^2spqVYUmp%1*jL~ zd#jV=*7sT`_cP<`_liA zgRif5Ft>+!Ii>mwI(2gC;k++O0p}Ipp92oqAt#9MFke zI%{ida%la(cfV5mC&dHyRla-o?*G9|X+J&efg>k3KpzQX5NZDKo6`J$IQb9d!2Av$ z08Uu%0C^SyjCthbI7oZ;>{+sU4Hz3J>CIqGY#`xR=0D&L>vg0$9cW8iwrnBGPmrVF ztcjME7Ghn<&d#QIemJbLP?9x2Y%o$sQ0j554?hPGvEa|yx|?W^^?bsAOF8~ zVtjg#iO9)C_&B6`^0u}%;`%!9gj`y92Ydth1IBdNUycuy^gsAA$vuP51n8rnPaxG# zl8fKhJLp3OGH1Zx{t^5i$odcXvZbU8vZt_42RhE7&IMrom$YA@G_FvhH|Q?$!OqT( zVjZTG2fT+mh1rvomzR^ZH|Q%p_6_Ja(6uHE?q7xf01u`0T+sJY3ea~_nh#)f!QY{M zKz~Had&nQ^AUWM4K0WpfeF6Av!v~n>W8bkm$l-mhZ$La!UoD`M0{s`3$3X6?s;Wf) zad>zJ^873DfB*jdWN`++Qhfy&SHKSgtc{Y|4gu|;j{qAUSR3r`+%NE(0{S{~ut5I~ zJ{LjHOwOhQ+Aw||592fVssKG4jNhbv70f+h3?e7rL;Zv~3#>VkTek$fAb(iLfHi1J z>ll5}4Zi&<{0BLok_%fm5JtEq}fV}d(;YaWv^f4fhl3tk11F{?? z+cBu;;Me3whoSQSN5lEO<^2f$L;nRbF8%^r7I;tECz6hJ@Ez_!zGXlBwc#tvvv@l%!2f9$QMHwv5vH0I}X6*O!31kNh z4(tx&)SniHlmFyw<}lg~<_`YMK<5ea!OsdHtALIG=H@VG1l#I?%4N9u0?_J(RM4M4I8`|Brx| zTE_5FfXe^jrRVRK8KDhYsh{_0NLC1z&r4v1->V#;m@Q1^zX3065~Iz&6GrTwlt^@xQ@L8TXt$4`1AM= z_AK~uAMG1TM*en3(4c_!TFgTG@T;&o0Q~^$i=xT{zkfXd8(pw##GliSY<3%webyr2 zJrKQV~+?<6E71Tntj@2HM2qOl+n zlLzp1f%;5-kM{3E7sS(EB=5zcc0V7_bJAy!2art25Ay;vH++TZ2Yweksc` zTfCqs1%@5fJ%YClLCXC;dFdUCYCqf{vMnB?eCEe-~Og0 z{r&A{>Mtq1^!M7IDCsXZSuFjN51*?_t=l${+=}0@q7tNFNQ7$bkWkx zNB>a}qoJ8b|IbDIP5t+k0%*S~4f5^Dj`R#-fAWRSAK^g$>A&I)56D*!8`2S{{4F{b zgljs&c`4wI=rITRnW}?*%it3Ld`RMb8~#bpfE)N3{nxivBhn$yM}DUQz;57I+NV9( zzHYGb!51;?RY&z86ye_lzNY`S0KRfjy>Ep!|2Nu)z!^+ffA=@ykN1f}#sBW#*8GWU z#{;SV;G-DM_8I5}J{SM106zBy3J1i4vq`{@3Ar%P`j5tlul)o@um0{}kMUpl8QHUE z&!Ay}b2z~_GimrQ@dx^V&$ZzWL%|{4;lIXzpl5cG!qwOBq2dp|MM?dV4itYl zYY}|m_IH8bZ}9JjwT<1oclU+LkiLPx&Hmtk?;zX2nOPVt;LDEG|I|S79}J*yW&u{_ ze}VsCV8Nev{G;&4`~Cdc_`}(*7%#pDfAA9qeuBa0uc4tK*$W$E`obUPUf_2OY^^Be zgT041;NO_kCq3{V&Om_kK(M%^cNq5|3>W@zW(`J1O85To2U|6mUsFm?={?xCVZPJ> zCpaqscu6TfyoWdth713J_#^KRe>fMRFPQuK2EK=}0RE%pA>)a zi$^JdKXN>eDd7*e_hrxhU*O-L|JH%x-`6>ad3kvQ!XN5QUxAAM@Qi=>`cLJ5-~NlK z^aIVcj(~-uDiHX6oA()t$3@IP57bcZk20H)6*CSHc`uctC z+O@B6!`ufyAMYz2{u}I5!Jm73%p8IbX=rs3;#7A%|{hToqI&q+y1eU=aTK-r-CxqtsY z@w_fLC#Ud#Z`d8=AUJCh#w~n+v~WHJ z!~^{ptRaHTW@TkXdw7*fCYn{^ej@y8^4bb zK+zSCTt}exUf{^M3K-#nA8ZCKlTmU4#0&E--AD#6^gI-AY)_s!x@|VW9YvwhcJj6QePl=ZO!{1~52b{!2PG4F!L!EYdsZGw^dY z{JEhpc47gKCyXmtz`yUS{$o4_TwqQO{U+8wqrJf2v?(~>41*K;9MUip{O!@%&0bz! z#0y}7vw*NNU_An0f&hHNXb*6KE*|V; zq7lzY!oFd5fE`=M#p)7_X)wlMao`Sgf_80TVe!p-EDq2f#sqRSAQ#~S$b5Lc4y=vg#|Dr&K>q@?g|j1}-T{w6r-t>pkRRw* zyuH1Nx0xIM==$m>g7wez6M_0S@N?ja z`nMau!-e{4A2c=ri)c`P&5Lv~A3*;KdjeoD)USL!eE5*y>+1{aB+ckrg>ZRDsvpGa z;IDiKdwc|Y2Ew5m({KOE@RGs?Yt{(%UeM#gn#k`Kz+M3K*i`s`4(Ix#Kgg7@{?*q7 zYh9%4T7$&~e020x27CiM0}l_6Z`S(ygCFE!Ob^u89dy0en$%!nhxo9cudn>^-|*{5 z{lTxPsYy^*S0{UI+qUiN`XH<+eNX=t5)wkT46JPYIv8dL0(kcaKg8)PAYVm9x_vMF zeU$; zoQCxcXb1QUZ0jMAgCFcUz!m@>pY$2}Y-o#E0NlVP9@a!j;{xnMg&*2IhBy9>+1HSR z-_p{OY#Mxe>>1V^A>h~3v2WP@d*BCJKt6B>9SrCu;KFmyiLj7^A7BL84xa{|F`GpAP7Z$1sgkAv z*$u;aF!1ATkg)XR?$B027J-1-)RK#be+M{Yw8rxUABWsCejUF*`3GzX0SBNnr3++F zyd4?X*Mi(fDL%Xhdn{}W0h~de09gv`h$y9lZ!jKUbb@sK!4GjDfNVnP`X|Q!|4%7C zyr&fUN<%K)K;frU-p_tdh5zSrpg;OUouf1_1lu;!0P(?QkJKKOG#>W5ulykne22D8 zy1x@k-yi(oYXz6RD4 z!DbZlC$;;7aS!?@$PekKh<1cv`$;-S?F)Xu8Tx4GgYmjWu${zgFI`+*h~R+@BE$pP z*vZL>0RFikEv)xJ84$p>5aPht1U80XzliPW20b7?V0NcKBd|AxeG8BlWNR!xh~F3d zPzOli2fAQ(g&-e*jW57~jnmj34B&fscsLPO%(fe|`7bOi{0b^L_(9%>a^b>kV_}~y zHg3ay5-bb`e&7fAI08C=oh#U9Lja$%@D48EH;nagfxi}@9oWo6Ff}zL#=+M`41WBW zirM`Geei7z=B!W#=$)}JQ23!<;O9*+pTYdA;AJbY0R=l#{CoUmy>Rj;^k*gmScx1ZV^>fc-A? zA8-NA@D9$B#psHqgFD#i!ukWikJWdOZ(-jRv}LGIfD`BkurN^gBO@b;eHgR}=trQB z0a#$aGX$uoSp9_eaKZj&SObB8>51Syz~SiVNPLg^O??)hm zfx?eZ2mKzv26+O#umC=mVZRd43gYAUwnH1h_67k?Sewqx%_Z)afj$6iQNf-YV1fX? zsi9wj{9w)r?V2?71wXV`Xr}-t)Elt12OoAA>>wvX-5+Scc!0^w15MXgyuRRvbRf$^ z9}F;K7xL*%v~L03)?fncVTATgX*?fHURd1z;K$1X#G?Qjdz_e{=oPOK7oJb3t$AhY$Tt2L3#;!BwNuS z9M&UT;?VeBj>fEC`T`iy`1t_Ayb0;iz=Y$s4*%5qf6kLZhC7&{}vc$iP1ht$f(~3;Gt)3)WL9>8wcU2Xhe8^za+<#@m&U!ylX9z#ND4 z8t8aV>XQ|p56nqn?hb)m`Jk^My3x)HEn2@4A&>MHQ%13e&LnES$a?85wxfNlrlKg`du?_h^9 z(DK1X1|P_k5BfxWz;u!L@}X>e_+82eeK0-%9zb)b{}}y2KZ_68x;q|!(tS!m*Z%4s z=)$1xfL<1}Rl@X0pi2S0G{6FDU)XoJ!*6&Gx=5(^@C@|`iv#&VzL0-k?GO4W(4zpH z_zUzbpqqoS3iOtc5B@v+1|2o%B0!%6?Fj!Jo*`dM&jqw2*Zwh@LLY^{u=m&<{|$S_ zzF~LlJ$A?5WB0zw$Kw2#_g}Gmuz>=7Di%P`31c4w(4)ed2duf`ZT_HLfQ}jTXrMO+ zpRibf{Y&r-a0PuJ=m)Ssia*p#sH5N$6#|T-P;bDW0;~~0{egZ4^vzJW!LA(ghq?|r zMTi4BQfFspVtU9A{P;s(3E$xx=-;r=SNWi;gmj>920uuE7nB2IA>5Lj4v>jKzZON25ix=^?X?8!un(Am&lb5b`VhKVXX`D zfUyLEJzCoWT_b#hKnZ_XPl9!R;49W@{9;5*s zC)kH!{R!{^cnS3h3zW)-bqN?l!Dj~OxS_s5fVu-~Ghm|u<2eM3#=r{*1qB6PeYjz3 z74QxA599rGKwQ%D;T`DTp)Z9C_^@;5PNMz+zL5)nGr$Y?!3J{W4>phQiBB#65b(DF zYY*Q;%fY6FGSQya_o%ICpgB+%v};UO`w@4v7XAg5tqpTv)K+Jsw)w-(k%h8QV1}|C zKt1r20+bDU3fNpZA6;Md6r=vXo&w2)3aDRqNB!BLFC=>@BANVu1K%Jk3mR)X!5<|A zSWCt(ay~9W&d0vP9mX-}N3rxUenFoN>)6mglZLl%-+uK`4tuU)JrkcE>=R(lj-4?C zYk%+?E|7s?egyqB{Dv_L#!Y;BkY!<9hw%m0|6!~QLOz#ZehBO9AbY?(0e-_8A!< zUm!p~kEO@v#jr0P(i<5W5#zv`C24w?gMxh;jDs*X0e&D$VDG?I7i`hsH;iH6vkDJC zG2=o0!>7l5QUQK2t^@2K!@yVy@)I_Wfj${@;UE)W>7mci(9j^hhrS!^Szv7#*2ZCd zALiUJ4#U_8_T+#Y&=8*j z02|l_!~MdA3q*VX7m)v9JcPOg0ZTtfvVDyX=}{Z#`ubv!4@jTz1=VE@B-;iJslNz)85B5U*2NZK7-xTx>m|2hPuXUcdg*nkSSVY43+Gqr|ljXpbEMWse_;{!M~^-8M8dBb?>8NHEaN2DK=IIuC`^`-B z4EE{L(5%YZt)&(C=51wlCTnuCyu7kv^}Om?OjDSyB-;fZaXaEqJ7KE#DSGCSBW%PP zA9O!Z9;2L`p8WCs?2|fLv61vEPaWY)J~!dctj#PmyMlcRDs|@fj6Rv4B~9m0>B=K^ zZ?mGn*&Q^y^qoctjVxX^VS>yWw(uGA6DDMLj`rQ?U(duw=ZFq#$mWNmFVY-;_kxZKDrzg)`Xft`&I1i2O z%3~%;jTu8bX7remW5zntIEp)3JC2?p!Bn=wg*Sf1nUzsm57Jmur$6R9Qa|GM3Uk&^ z>uUwM&T!4;irMygo4VSETWZm=yT7E)%T7GM!8!O;6}|q#y3yPcQOiotmC_#yITYGL z*Sw{2TJ8DN<&ToKiAiWVU5t1$R%^I(2@iI=p^xhH6sJ53AQ%|qnvNv0b zn=Y!wn64>vUPEBKCVQEHNK@iNyYao9w4aYmJ9cKw2fDafT`L1P)^q1AUAi!Sp`Ko2 zVC>lO`<&w?wKEtv3>8g6&2_fv=;=LsmUuQRP(Ns=!p-oHJ8oG@s@7kN7M#)L%cgRF z?<~WFhR~Oy`w9%N8Hl`ETBurNcSk8c;_8w_O?d4r;H@XuE6i_KQo%Hv_lv%5 zkoSuAR+~?wr5T;MjCZscsnxa|=G>rqL9;5_vfMh0;gx{@zOv{SI{#=OJ!|spWV$)g(rpC5FS5z zkhV*%O*G49K}*YYxLd-dnA&q2t5kcNR~5?|o~%~_~P zjNiXm&76jYei~e~H0J`)ORMve;_H+TX}*6wK60i?(g&+8X2xb)%uLLL;{;zzmWK$} zZ-IZ=&#e55pWc=^>fj)gxh8n!zR6Q`QW^H?ESmq|q)R^i!f}~4vwRe$>l_dpB`z)| zR%>d}ll4R>G5+z69L=0hkt*-LNZJXROib5|2n=geN(#HC>MMOJ&b+Rd<^I7Bs##~2 zDY3D!HTdfXDy%)+rtB`#b#-sSRnKcG5ofNcUO(66XWz-Ve%ylNlh<$NP)xMx>{zwE z<>RAC5!^fCudX{bCfs@2;%ggE%?!7A_cADe}7}qt>w=Ggl>zm#4noTa_SVP z+id>51_i42k&0Jbnl}Of2#Q!wcG+{Kv)3H@*emJH?iRSls>MFf>2tcWq+|wRrFHAu zPWQEKMWcnT=$E|xC&lC#rQl6&hi$*K^{iehl_Q?kPPjU$U4f1;nLjH zKQU4=Zb&KWzGh6BAC|rPV3SSR9R(t-$1SMXq#8O;#HJ&(;KqKzgH5G`h0}cB(aX*` z`VaZmv&xcf3z-rx6(U{G?a05c$iwOGzj#Pp5V7sD`IINUyyr%|YKKu%X&hl) z_RQvhh5tgvZcuPF_kFlhnB|KZkK}IaJ?pYxZP}Qfu<;+Nd91u*R+9;}LhV|F}+S%z$bp z;nvn0nu}#dTC$mq-1@%ct_p(nUlLu}j5r(OFw6bM4#~TFUo^>HSZ#4NioKH`)dTy+ z;@fqm`C{gCtt!Sa%nm^{>P56}RzX;h-L>os!eNLY!Khu*=rlFFh-i3alB9B8z)11b z%w>wZ(@v`({>yW(i4K$UUAi~Zhr`xlV@A-Ckz<>98CEcQ-d>lRv-L(&Z~SVr>XY$@ z+oSC2A*bq;E7E?IDSQ?U%AZjKndAP-+!IydQqHAIt+y5Wl+HoS+?1ajeIae$VVQ*H z)Hw|)C+NG_V7PsA6k3iyul_ab&kaoOHMjlW>X$j&SssbvjQdn-o|9laTmIfceP{M9 z0as76h(-wtLxEdg$6H8W-*I0Ru_pVFP0)2dqmtHIS0>)x`%%k^1+7}DQ#5v{Z_1i6WyXsm+@{PHu~VZH2tPSJ*faPvaG zapya;cJ^N0H)3yIh;wsCxn-brL%7_56B!fL{G-h4vPJ#v zVxOhEX`qJi{5(vU#I~2lnr?V@L0B-+D79epzKvV%$pox2eGs*>jRDa(qJHcv7maIM z6%{WVmnU8qoQsk;@Ohn>Q*y^(cjM@L3nw`*hRNN&jgxP z#M<>(cRyfN61_R&T|3K0qjyOH`-(o^t9L5i+}`wBjVQ;AD?t5N<=D7$@kiZUXYuU1 z5+$H3sFUt7H}b~Cu_xH6gl(C-TYrfm>iL#(YHfY_ncbXM zKRYt$`uQuVu1vvDX#~dtkRBPfo89IJ3RpTthaPTkT5Rzoqe%2XV^QX z5dAynzXy9sMw1lcO_cZ!@{_)~! zQ+=b<=^44ZbK;wP0wdo(k2T%>?$nAkoz>nAsLffIK>Uw~ciMGcxu$kI^p=tjTaL(= zM&bAtpR%(&yKDV>Z$*cw#~5=ZLd1*8)e55bSQfKJ?DzBW>6X}WxVQ9eZZP2%>aqo8 z2-`wE^LL5%b_55qL+tLj&QEo7zHC%$L6vSagrjkMJxc~(UfVsB@~ALCp61G; znCjMdg*ipMD`maeWvk1A?ejj@9qGNK5}{fsOV}LB!NHNM1R|q@4yrlJ{T?yh-xO-q z+gYby-}@I6YTb zb48DYzYeN})*TQ}D-AVm<#p?h+&ZS?mEpaZv0-)V2Np!_gi4%+qt}58`_SDF%)J_?-b{|B!DQ#RHj>el?r&wjzt$Ub2Y;||vGVZu< z*^}Gq$D7eymG=22kRv(Kv?`V9{$_UnmAX*#o1)ndB zc+b0=C4+T?)5M-_TZ7GNLbqI4o!siTGNZh=O>nIS6q|k;)zNS?G|dr~%)4V)*rdP1 zEz_5ii%Y|(OIlJXT#?P9xpL#baRY_SYiAvn%u7FNX2!om<8-{``>td4%h_ZJy*-~- z0BrN`Cw4V(?p~h3y8OY%^*3UfGak9i*e@{9wEe8IR)$z0cibKBhLx$tUVPEk2jjqA zBEQwizxw&54c2ENf`=`dPr~P(FYTfl#)!qKqB7^U-`yLIgp!5@F!2y;Tec&D8JD82QRS-q z!y*N*(9(3zMQ-*Ud}gcZp=h(~sP3np_TsBSovm*7v5?~1`tI{)!>8(O2ilRYbJ+|s zp-b%;D`{T)lxZy=d`iD?`SQZu5|Zc4kX*8|cU>NIY?Bz^lbL?m11n;NdtXKxbRT04 z-*)TryqWN+`eo&lQ*LWfhAP*$zd#u-%vlAvIoNEwx6l7p7^|$%oUAf5ii=PB2R6fJ z=lD|t5p2CJeo3VgxO#?5?J^S5E@k>HH?c`L*^_QpaK}LD#r=aOD$2?sFC*2a1v#T?EY_xQ`s2YZ zK_YX5a^F1lP!Cs+i9!-bSmkXQ{p^6;8_>q~U1nZj`K9fB$JFIgo-781Q5Vh$ALQ2e zMW49PCz0f)=a-H(^Di)#XuBV{_V6`d&aKzbp!Rm_bQHt?BJ=1is@uG!@5$U4+suiC zNJXSiYrf)1(;}f7UV7Ki@0^IOwGBb5r|YvyO{Oowc<1|~Tfyg6nO-irsXcP%J{pG& zOC)H$b;ERL@aG$@>y~}uGUEjrh6~WNsFjmpJ|nkA>_yb^`gOGLMdQc0mAWon6;3Rz zNuy}e88a0wRx@pkPLRDZQc@y>HKTHN%TqR*V)OQ<9d{4ZDWtThnOuCizIJ?ehFPMz2)oirQ%qM$w3I zE1Vv2;O+ATRc(vwx71}{sFLQOEo#n}6AedmnOW^I(V6!kVxu# zxjdG*eG=IVX_w4V6TgIJO{qOkRjYI9f^1rxd!13gizrr`wLFQ>`0!L!#|ccAqcq|! zm(QT_^75^f`EurcdZuNh*m#<_4MKFTktcOZ+E^dl+55s}#(Reejjv*IV&!x>5iRpY znP@zWe1aX)89F~Vyx>Kd=~b)OCme2XI5^HB?`>JaeN+2PJrFtUSJTWtk7z7p({a=E zzW2>V?fmF^6b3eyMuHN zqKXtU43ry7lg^!~AVx<|@Am4#WasIu+4E^9oq0c_VV)1vb@vf89?KA1 zcZ^?`#J(OM#}fD07>#>fb4R`@)af*y6J+7PDZ6(hP4p>L6q+4CQAwyBSQMbCRO|gQ z$+n$#(^IZ01h;Ocp($Q3ME4lYhh1LbrE%DI3B~c26X7v7Uq8?19KYc;B+1ktoHF?yYMV?b;uso_9X`Qg@~1b4 zGOg4(X~#&bjpigPn-;}At;zEHya&M{CPX)?=A;iB2<{(P=WFDwZ4WRdr0Zq}2n9=w zq)8u>sW5+uLrUGV(rV9MYkddeY|~>Ql4D%v6b?hx$ZeO(WyQ`ek)ZLg z`=?N~{0CO%+4C0cX_>5=pXixo)APB|nh(K^YSK|N@qN|DCW$=%uw?!k)HQ{eCZ+2p zc2v@@olPSqAb)zqI-!LEdAD{;^qd}%vO#EsLn|T(B-%J{$%Nx4%|_AGO9;^&UtV(+ z&E%W6?MM%mJzqIj2es7ZU877MG&@>%*7&_ZlkXR?rd5?AuYl453FqN1GdO40d8fLv zh-xN9INU@-=lq@}5=T6a+2#uzTqb*tFLzN}7OIT}s7_pMYb*|i4hzYxk*g29y$GFF zKv+(=La-zBY=J_PCuUBe31O*OGTA4%+as)a`OZWQm|Lydx%0NgJJq!t<};%)db22z zh@I`7bwLqGxSdn?UOn~n+z9>A$B+Gs$f%n1xkw6@|NO2{FuUB3Z(oso&X{py(NLb} zZZ>^frl-1HSN-A-ISc2mwN^%7M*i>1VVIk2a-U~ZI#EQYKJSV(yV$J%F`<+-q7=z3 znX$_>a-f=VIjq2NzQ`Q+4hiB7&1o!x#*_T=?-{AD9XWr({{S`?Hm`NPrkN0LdOmix z)yL}^v&S7j`agheO_F-Q0!Yu@>0_7-J6mep zs=AFDUS5wtN}$L<+cvek`zzMpMGd)Q{=AHI0q^DhA{}o=@*_RaA>E=x+b`E(?zGyX zVOET-!NLy_scv(Cu8%)FLYkASf@Oy`*mgIkHK19Ljj8WcVms`fpY)tl<(f{Z0Jq-5 zqob{>C$~>>L5hKSEYl^A@EFBth|en!KJ-zG-=yq8#~Y84kxRHIXD#VTfVQP;T-7`T;TevQ%s&Ig$vR&-94%AR=U3SZqqvrs9!gXz8t%nbNSQD=eL7>gOl34>R$ zXD0_)uohlTYAZ3kW>^@NZNEfxN&BRm)n=1j6zkMY}u3oT>Av53j00l8j8W9s<0?9w}PZQQ4`#fct$F_y*E%x8|7y(V|!ArI*{)r}@oTQ(jI zu(f!9YDyfV9-3r?mpIi#p0$}bagG32V30vsqD0di-@}*fIx5DlHH>v2b_=3t%BqJL z%l^J*v+-Ns+ni`3&Yvyn=4nz;bL_~RQ}*s=KDJ1|m1)rY7FC?^oPf+8Da0ojq$3!@NEPdoRf>^tf28uNQOv!dPS3crll=o~#o=sYXu z!{`HtdWnNTOB$MxxuSX!a-G?zIGRm!MkZgO{3133qyh_9H-1Z7JwaS{UvgWKw;(^b69HrwE3B6xB^48@uZ*a3w zTBg6LrM{=VT=4X=t)9~CvN}vQjzq;t`cndlCCzBWc*O7Y1)cBGFx31S0 z+m>}rou1`Ap{6w}la>)$XkNk)Mu;-;wA!3&{tz|NDks%23(ph#HZ~pfUf$wi`%j}^ z!15e;rfF3(W9{Cgbhcg5T>&4YQ))TQK0J|Nu?>j{Woi3R&LUcs%E!_oT}`u{PABU6 z&ZM$cfg1XikDaZ>>ur)Y)ApjK{CUEs4XQ!jybD5`J@YmOM%2AyR8Axg;nkBmEpzS| zX*@-`w$`gX3;fKECl(#Oo_=jx^wYR^)m8#9W6nO(Z5z^2+r@8DvoZ};lQjKX)d$6q zKG)4Ytvx-3*(`NUq0XgC`a!lYSFwAoi4Mx7T@!umAD+zQ+NOnS_a<)P<_y%+#YJYC zAu*@6J7IH6^2YokXQ3-<3D0HCD)ZxyHw=n$tV(ZTO>l5c7q2VHP=emj2Hfk$Xo8&%fe`fB~ zJD2s=%zOMa_EqhH9=c~`ce72(M*U@YjFu%*r)771lC)eT*sh=dB4X={HxG}wiJmWV zXBJFXlPg+B?4BmE*nB)6*3isbVcAnP!VMcz3q~*%}Z^Xj5KOI66lv@?+ zq|@EpnWL>%T(7V5Zd|PH_sFjO^}*&(xiX^OI)Y}Q+(tPeS!0Ryt;u`mo))7(gZr_w z;^JQFn)x3owb?gYIKvo?Ak&viei)%quHFo>^EQjX>YHPTKe7V7^A2tdCThb zDIyo=I@6D?cM~jIaoTOoDl`LqslMWHwZC9!kgVA>HMD{e7vo-cGR_yxnIWZ@)n1swi0*?q+H_pk)+t1sg;cMe- zpq9EL^MQfJ;{ddzP+k4m8dU=~(V%K>UAA^hl;ivKYe!0t`Ai_zv-Wf+DKraqd-d>h zdXECP=WP>_WqMO~q4lefo!3h_xw!>SJD>HAa7xb1;pwsQI-jn)U)L1rn-V=*SdlDM zcw@HIKlNX@T5S`WQh#jIKDN@>%JGCWOkQ@g8S5f3uWLruGOL%q0Sr8D&fT`>1dPtz z&YG{rwJ3=rtL{DOGnLWc=}O=><+bm5xASv^lPH>(jjCBfsBQ^FvVYoGme9B_Q#lrL zd#c=(sbX!~=wgu=Sk0X@vgYBFP*cyH3;iVKt~n%ca;x!h#R*2&s`+t}<%zbRIXwe3 zwi|T`I^XxKYDFuCo0EpWkt*LhwFLXl|cn~hLf-T{_v0Rmg* zvM`r)QZDRlO=|u&8=l(~Upyzc)8o)|<)U8F1C#Cm>m*9@71h~Y&Ly5vm;|Z=>%gjM z_g&!e%uR+1yFj<+IGpD8vU~Zv&VOAPx3DGn+To+3zB2=lENC@RQDX*%y?&$W>a%mS zSM^j%|GWN-zykZao(j+Y<8x;=yp(`hZ)ALL&ynFCqGI9GQ!lvt6)-_*2|Wr8d|U&R z2nEt(dy9LpVSO`HjWcX^$e#|kjgUPQf+8GKSqY5Iruhk%i3XU=##v|kZJ>p zXj^T@cRgl9ZE1s42DpCMAXomKOmWxlk|;w1l0Pi=OU%A^%&Kby_l8lC464n_l`EN} zkHUm}rPE{NXtHGT*4?K!+gnP@MiOsf63F@X8IV!H7m1yK+a}jDZ0}b7CIuIub z&ZU;9isnSgNqt+c`oIl>Igd!vv2=24p*?x(t3~=W0bqP+?~n1huIGlMf06K^5hbV<{dkFU!yEqEx%*lD81JlfZg#n`n*^?~xdUv)FZ zk%dv-NM`$65LY5rS&k2%Zwe7P_WrnxhAG0C=t=t0u~*6w1V@YP)x z@SLKvw=fQ1ttWAc5v+=xYo@LXbkUOo%WvW1loEuWpdt%%YOn;f;&}X{&;<11fPPhkNyQfdHRa9^|=iUwB@fS za5KSi-WFIgh)WdoKcWhOeL^?oroWV-s)9|)`>E0CBnVdCHP5JG=z2BwzwJm^Yqke+ zH7{U(rY3`Sg3er0Y^Y{q^U`7JxViW@V*y=-wS*cjrq27>|ATE@tJg&!LFi5>C3=q!QLIGgtp;u1&ca#*LRz z8+)(VB-GR3xL+ebAD9|>_46ub(L<~%K?d!N>huorr)L6CWCu=XjQHX#Sdtm{PcIzm zcxh>Nk#Y^gKdoZ`aN@5Wx21x7D1&j)d@_#Q4% z6`9nUQG@KW<&hW%d^1C@=|fpSZjl_8C}=ejH9u6;JpTBL1b!yDX5>7baMd*W763AV zTU9S43^!=+`!@5koApf&Up4h0JG=S-ri|Lvqis2hW@; zb-OS0vRlgN-dRENWn9=__C(CFn1iJ}%7F}~m}iF`DnJWaaB8lrB! zk}#hbC%1h1yUhvW6ffDrTiP5)(dU)`_cfR|MZpy1BjeT(nGyN~q5NEU46fb~mC6!)2L9m#u?zOU&^ zPUMb5mN6ZgEXGM-&7#IIG9X%L5#O+hy+Fip?hMdNJfm)Lvms%L9vccAVDIqFUce(+B#**ezzk_A-Eb6hC+7G19+`6dbMDX`ePd1Rbg&5;qesi94`gPIym)D3{<= z9|SDQO>aXfRi{~-{W9uWCyhNlMKMmT$Iuh+(q-lEw{PBz?*MQl($iapm98I*xZ3`H z1Nyx)LLx%D*yiF}E+86B8X}`Hlq2D4pPJFQ;nSy%3kkZUq;es{CX{0Xc5(V-w1dp| z;#4&s0_s_MtJlo9i~4DDT~V@;=QWw9md{$U)^no=&iVxcqL|09G2fVephfKJP}*tL z*N}6ETTE7+SiUO}n_f0mDGpDpk(f2yT{>v?2tUXjumfOFA(w;K23p=7FtGyJ&_(r@ zU+h40?M?!3a)MZf!ZO^fj}bf~!Mp{y-*LV9jzW7)nxI{H#8Wf0AaexUADg|caLkI2 zGb}KMM|P>`H%#B@kn%_tX#P7wE;ra~FXs?palMe*fd#MDTE$~zN$SRw?*m%kc4zAj zkkLx*|Dl!rwc<9cJ_WEQ-1-$TpSSmq7yuTOTJA@p8v4WP?hC+B-LnqzWt$2Pp2_4%|ySs&&*z6rgzfO&%-J=*rvN+ICa7oa|9 z&Su%k4bF-ZtiOD^GFo_Lx^sQLACm7~X9V2~!<@`9*mAwUX@`2>IA)sY4D^f_=H^xb zz>3_`iev{`AH@i{oPx284Hjut-Z|0_=&8y4*#3awXQjRS+&FnHZEfZ6!|-u4egMeU znq{njZx%5`&!!G2h8$DKTpmo;=PMUKJ;oA|=O%5b`L`zi4 z*_0TOW!Z0I0@L=)Mn%2lT0H0$#=J#QJe6fs3NRUe|EQJ&F8c$#5T5g@GtU5!eFEso zQviDS1528L;4t2`HcoBj7bQ*JGaB>bC*}*Ta>C}*+3*ou_Hn(|XRy1}OjpDLB{yi6 z+YQl86x`tcA@7c?b)0OIME0gra8s(ga`#T9C~eM0p>n^@o4Y+ByX~FWuWXQ^SA+M0 zO?Xe!mqg@J`(_U)gAt<5*@fqe2=KFCnG-rMPJpp{^soaYPLN}-c|%anBa+1m0aRrASF6$9p%~Z%y=z*pcxkS{^;7;Yr~%{ z?8pIj%}ojk;$l9GM62;bZ+3$Tw^h(3d@%V8kI@9HT8v9R^cBND6ZD#ZLEfHb-?>p>YrP) zt`2j0_`f_jZM6C|IBRIC$nU^2*0Lwcdn|aMNb*c14HoBni$b97@7=*^XXWB z^Pu<5ilwG(u^maS1)BYOBiie4HZ-fQWqwLyTkr2+^z(LE=3f*nG;wKvl30#m+)YnR z)mWIiK>MWcEAAYLq<_?9Zr~d;FsgCX>CDF`^Wr=N%3?w3^TRARwShZl6tH$7N3_Zx z2qrdGjFK)37P@cWb2g~e5ZiF+X0Av4__3|__cZ*=t*A{WqK7aNbXA-C#vaux%%!BN#W^erXQ4&rV!hqJNE%Q9E zFU#YRgA6Olo{g2f3SGyqq!+XAi|rYXdPc3R0~dRw2=VS^3|^7eGXDk`n2G!z4Cs|; zH|{JVLNY}&kfehDkPK!Nx<#C`cCZ2bIkNjzC^*o^WA;_ieM02z%QYb${@W50Xut_F zRqp1#nB8ps;K=vBhaiU%s5P3qTUkrDp|S~-+2PWe_6Y%4`}hVkFbfmXt;n&jas8?V zKVC|}Ew*kIn)8+ZIb^>t~^uL5QV z^O7MFNWe1D8y-7{UaoDl)`1Hi8Mc>PKd&mNZdYJk!O5x`jd@9eooy9g&$cSCL>KqS zxYEB?@BdQatrFcH3KaFhUc6QVKw*8s7^EDdIhH!E`3k0O=%FC-90YXGSA!C_FHc}6 zMEFk>GzqXr$5NA+pbJ2Ny~AT9oNa;DJewBm?lHWO1(LdLwF6r8Teqhx2hc)6=FJC+cE!rAaOKb9TEk4{o?5xo2fE0o;vKeiu$m|IT5|#y7mzyz!0yk#-6^l# z_N1Nz!qk>5UGAOfG9QbaO=wfLdU{V!C3XPWPuo)H^!^@?!oY4xEsb%;B4WkPM7eaA zxc-FTX@>l3)6#ux$!SC|z--ZH@qozWj3wyV4%l07ce@Mp4;ZAVxEJX13oX5rohHPjM?R_F87JtxuaUaIno(OT9C$@A zRTei`tz7~pY#;U2eDx-i$H2;+UW^EU-RYPrCL(Q%iYu4ZzAK;hz_htK?;!&F5X_k+ z*S+WJeCmT1r>S4xe08Qpn=Pd6NtGT~#(=~2?m5aSMP=#PKF2-wX_dD1pfa2Y zf6Ml9rAz4oWxyOjWN{*!0|0p2=3SEj3||t&%leEE`(alb$coXpn-S=k;-|dN^SjY} z{FiZv^!aP=H&D(?x*So(#5@arb45pf`ry^CK|(L=7k-zLxA)V{={GcXPkK4tWiPf7 z0ERyd99wB1;Dhlc0g=^kK*~ytJPCACp>C>tVb$-`O;j?$ybokJ6RUf~o3HMRI)uk@ z52L~92Ve9F@WwT+edYiK?}%yNxzi;5{Q%xLITy@|Z}qpOp%(_btJu_9O26!}%6j~j zk`P|3fm)yZrZ{%R232vVl*{s<>EEi(I|-`yngic$oxmscRa(drBtj6T53QfhHA;S{ zJESq)@(8A0jZaz97Fo+5W?>-T0R$_+mK>n|QG1_V)O%5T>W_+rq4jsvij~)cXgOoj zV)>X)Mt6qCx3df2PPAn)g@GO@ppH%tVyC`^`L>P3g;mFZrs6hfFUcXgL1lBmD2y~R$*+%x%79l_hQ%Vt2%-vhD zO*MLQLh-#adnQgx<~#%mO^qt-ha#fF^lrJ<8@3T?K1`QHVIOoRC#voxW!`${@w5-(-gw(p(%duq8$~mG0Oguk{RNUe_-Xfm zZBMdIPmEiYAYNKEJ5o{OO2d4ApBz6qPXkfN6X)ji8Yb-g#r)(YLh?3bd3-+Lp0O`r z?uL45PaNlBat-Y~T`m*+`eS$nd&_3c#Y&GJ`J+t^L3zV0h1G9 z{{Y+wvt*bv1J?A8^6P^qfoYDSVE`j^2#a95NyKj;I$;*s8hK7iVpi!k(dj8^QB1}V z?r6=f=Czmsn+0x-saMkthx!{+AL$OJgUV%FZQ3AxkZWzXwC;%C;{0S(ckKjal#0X? zp!olh?0}zh`_Wxm=pm;OQltZ{Jxo&EA;d}0di?JSw$a}rACl$)E0oe{ANb5URv42j zN;SNOS0as_DK*0DrK-{}{_w z+4@}Tc#(gdVzXM7bI9z{6KSXWe5FlL$k9E163(wN#-x?w=NRQwourv0_gPQ>nqd&S zSOCJS6kYd@ayhlI-zD+3+K|;5!`4rp_m{0$RZTX;>Zr01^ApugDW&io#V9X6EhN=X zMJ}M^m=(^`59QZK$kjRf@RKgSh+;pV&GB@#?S%ywKCdDjO_FZp^y!UrQPVgoKLPm~ zN3ojbB^$0V-r87N_zk_Njl6JGRYUH?aEtmj!riRqm3J_7Zqk=IVD6{IkHP`<|{c#V}kF9)b&`@y^JEZh^Y)CoVNU8gDk zl4R{(L;?R54AAbsruW6nx_mCqLn6>QY&2pDyCioyD{c@x@L;RLnS9vya!{B&=c7>_ z7P^WaRIl_KR>?r!cx3iw7oVWqd;_Ut$VFh104>Uz>ZJg0Sk1j6k+a!}w#vI6rwMwl zM`fn>O13Z+W7JrBl|NPmEWLFe3dP+vQEcA9yS{*$a%Ueth9@2 zS+97Az97)$opZQO;j*>C3|Ohi3>Ax0r?v@G%2~fU2s-jU%$v4CF8qNS;-RPHGqB*& zwgY^+H6xjHgL!^VP?orP0^D+@fgh_E=U&fvr5SH{%R%x;YBTep;PkPk0@N`DRHqC`+2lX|QP(%Z zAsRy$CT>0GIb1pE@=q0pY97n*PuP!Tr-)x0ch&!i8}S zhX9MWnZH|ZGE!8;^y&9=Aj=L8!klbfn4hIF=*HbqWjl!YJpv6Wj}XoP2Bj{pAs1Sr zYnzjd-{S!n+g5vQE<8t0Hz{bYVSOOJ>Z%WH2IbI+1+GU__qz$oUBBrY-#-@C zZC~vp72$%duFAJ5Jx<0f;3j=fCz&c-rd!J-`vM~wxtPm^3l5kgg4PS^$hMV=dWeHe z_rkKK)k%UI4>R>2$49<+|`$L%ts{>kin7Wb}aZP>hbJE5MGV*LZz&AGg62xf;*sRL`J# zg5hn;I_jc-ByQHkxI3P%3{XVVY|e-VyyNPyoi^^Di}ho+g7H>z6Sty${&}2rWH&V} zxyAq^Oq|wmRrzanO7ixwl6$YMdla3tFmstk9?$}?xKQ_w1l?$5gc4l^_&cjow=XS) ztM>tnNCAU*sb@r<0U@YwaTz#wPpk^lJq{7K5lg!EvzEQyR*PJ^}nnM+fq z1B9Kvh7#Ve(K-uD?c%3B+?aepl}_)^=bH=O?9y$ z-XjP~IL4spbE!1Ws zh~&OBmiA;lD|p9&*cNAWNH$ohCscgt^TKqb+URob@2nM@s7Os<5J*Wb zfXoVke(gA2%g1c_f5-aHG*!>Y?w!WCA`a!zIt189YVH(HhQtD)Erf3%KD~s1dRG$z zJ|B(=$kra;dJGLvvG?d1+V5s7y01gh{W(v}9wZ5(DZh6HNIB1~w=z$6fUhezaJHLz z5=7~dp5lQ*(^b1CJ-<=w_I&{7SkX;^D(+l+MZxkat!BLiIl!OR+~7#h^-{f|`@3#* z9nq?0{NP4UdeLu)GCOQGc7bJS1As2F@Q4NhX_n zSc?AL+gh@z*uQ9p^@c+s_C4mkczL@g5Tv^IY;cLmk39gw+?TH1%7c^1A$m>G6vxMe z78~S|cB|Rrntwqg^-fkb0Mtmn)kJSd#!l;9A^o(q&cEzx7&cji?9G_J=-b$mvNQdL zmjAEUoVHJfIO=)&R%F_phX_}X54zi5k3fmXQl9Q|f41YalC^DFIf z!z&$=-;2af);uy1*;fJ}lancV7Zy_U^g|KzCi*^QRqb7z%UN>#RuqFc;sHhZ?w~N| z@?ikSFM%E?Pd!2*V<&-f2P?w?WXHz%hGN3}a2b}X>g?;~q?=94t=Cq8(^@m^Cd*p? z)YfU!L7&C?U|CRckGy;9V<@1?CDU0d49QB(n~7cvbJJ7r$_YX ztm&EBVHkDq=sL&Mtl`tveBH6B=N)}6%h*Y)6Zx*d#>Om(=ajE_Zk~)i(6GXNr*X>W z!gML;F!}=DD^5a6a$0*n>@LiCyA(=*G=5J#IrJLtLK0|yt0Pp^fIwkdTekX%@a36i zYGFIPVCpc%iSmrviY4oqdgopQSqxYVD#R1QzO&j#m#1M4>m87Lc0on7;K-Nd9 z+kQh!ZH%z+B4MG3Ai*hw*&eMns7*?w<(m4@gN)>zPgnU~uhgv55Su*-y9_P3grH%s zPg;+sW)Mto_iU1)KCYLH#b|y5s!v8sZ>8!55zg0y9+KS8u?O?HAn0Q*h|Dg>D}?F> z^c(EOUBCfo4ZgI_yU9z_9rNvrb`7?F%k=l4Pn0~se!`69KW7PXi1V=v*#K#L|6V;=*RU`teo3E-4j0< zWBXHTRA74EchP%zelqT8v=3eVo#*p6NB*0X`QOY`$T$4IZxHA`NL~z%Lz_3%U)MYx z9Pm8k8-ckWv=WvFS!l{Z;P#m>B?jtY<KK_M3GQqL6$5nGNh#@dL)5U+LmmY@^;bTyrA;gp1w% z-$Cd7NyRe=^VemRHrngczhnQ!4|&4$juH{EX_x$rPnZ^zk>Udtj{wmi zPjc84c)Y2vzBD}7RX@D(xx18jj$Tt_YKf$%viR(GNarX-X{S_wy}OtY^LUGsP+;dQ zc6fE26PVMS-W#;2sZ^`Eblc;iDy^v{J4CzI&3+HABgs&||M}$fU0PRq3-R1ZOQn5P zZXxttD7zO>w)Rnja0vOHLQKG4$|p*^JCsUjQ(Cw`l)B_vYGJlm;}V)19i(+{i1?Je zL5cT;e4^8O;HtC(VUSPF{8kQ6gdGTR5#Wi7p^%8+)XZ;Kj~`0K)Mc{&_KTRjjvxwf`Yw9z-`ONbn_k7%fA%6uC8--peFQ>hurm#FXQi0vG*I^KY#pPuxa#J z#QHvl`~U&_om#ec)cQq>2lF7GBv%OGt-nR86Dg$apU14g&ez?NuAUB=A|`J}8?MQZ zB!Ow8LLSiq$M^U4zMgxi33XkboOl%NacE=TX$R`M9FZ69ycxVL{Z3NSx>JV$sx(DO zt0aw(vJ|L7G~j*Jx308>owS#+(dsQpKRk8KIqG|8d{L($@%!|#>P|V^GE;JVYyaHz z7;{v&wQ`Mz`cP^}Su%21XYNPZXF(G4*mvW0lD{8qP%9iXvPTe)KKf%EN!NZt&ap>R z$kfK)##%j*L#Ys^caC0!Y$mNMkfc%58L~0M9FWqfbMIVOZ@3gd-Vtu1Pb0~wl%#%# zf67Qpo5B{|IeH1?acux*Miz34YUq&a65X%0o&_(Dsp6OG!`o-mQtS>{cR2s}Y$+(C zbPBtYCjbK09)RvnoeRsBS0K+30>WkoA$ocO%v8-ZPv7O1=kE3WQe1qvVn@h8oY?{V z{#VFqyo}wwnyCtKFmr)dNdCH{ixL0tMnnep>x4Jw*%J5RJ6*v4hGnY|aGo&mQlxrv z-L^f4$*uFAkg)vQIPNnP7%@Q~zrI?={y0jtC%3+Z5aAS(y%nwyb*TeCYP{l$0xVzU zrm)!J>Q4V61O-oTj7u8ot+F9^zXef$8;H2wu1^77hHG<)W2E-L*}dt5Jt_Lfw~D3GY0 zdhYH7?E4LMdV*T<`qR0D?kd64s zMsUig2b{FD^q~I0pR4KPk6kJ@@oLe%+Yp-D`!xx+BH;J+R(tqR6B;~>FGxuw*n$Ri zbUl){Kt&+Yn&IPm($jqr3gd7!l78J-vqn-kTJWP^u+8laJ1z+-h-M;>(AOOja-~HG zPF3g*1`vAXOs*;gET{Is87-|k2=w80TP<-CnYHcyim3q0_#oiA`+lq`!KGEIN~H{m z18SJOyax2M)yGg4UNj!Q1G8AA0-3;@H+GS7QD1PO+_r7G#sch)6pW;TxN0B!N({qb z>B?AS>>vR>)1fX-(%1c}A=rROnxsU++^~CKEA%@>$>lt|-}k+HKl*8dyki-TVQ!52 z()V-ODOmYY=<+gOj^6;%Y_B!cllB3Oc%m0Fn#~S_ynEaLMynlG z70V3691Ty+6y)i1lGaTQ!_b%2^Q?ir3SASvQAJy|UM)eK8?w!R{JAEiY&%Qu@jaYT#l9Xpff;g|Qv@W`aN7U^$Cc#HqSLlHt?t``}`x9fZtV3nfe&Vl5&bnsr-dBWg;{H&VnJrT^wZ3z}? zuUmI<2qqx|L3mJ8%@`m9WB;*6m7gF9@%_FB=RXEJSL9?PYiS5Js=BO0s;&H4-yP_? z3S4j@RMYxlE!yqWobHNU`Xk_w(QLITenR4 zPJt=-ZBE2EBDYjsoPKL46;PD@Bq35LCZP&R1BSuyH?Jll=8rb`&1Pwa-c(A+LRs0( ztBBVC);?JoD6QMofH2l8v4)z9b45=8xnk~T$|PhB9g}~3bOihrS&f4IRZ<$zE}D_Q z=D%8Q5P{U_Jm+BBcm~P1Fv28L&~YcLf&q)rSDEq>QqtR}%BMEhn|VPwDfOiOrY!IX z1nl`T{>*}@GtmyAJLoZL)Yp^lub~5Vpe&v+isd#pVu*Yi|2)MDKP3P!qrQnAY&-KA zxdNAIhJV&rMgy*=`Bkv^m^-LhYC3E2pz_hRbVS;K^ffF;u+4w}@wc(hSs9k0Wuan~ z1_>rMyZc1J3D`iJmrqNm^rW5~zNDD}c*IB5`xsKV|JkCG8u^u-M0HrQ!&3V+QFh&97{>{L^kITinYuNub zSeo@yw$Z(Ti~j0d^weffR)YBKw*YZ(O4bIWLL4{xfq;8uCfsannk2qt9$xihiv4H3 z;0HFH1$I$?E<<`R)Gis48GaXe4WEYZ*{VzS_iQVGmDfe=ZP8&;-MoEnCnXHV#u=LM z-AXBiB-gg7r9NX3u>^nO5!cyfFE0G&wSeQefsYh2MfJbs)@*}K%Z$CV zz{NknD@D5)u<1Qpalavy~iId^|I7SB`5wv%gj0(ft!L3su zxC57J-Mo7G&-U&jt;7UQgt5g5;`#{uLS=`YZ`*=~6A$UBh$x)_4afNV*!3U`Fb4{MuUnu!v=BgVh=3%F zpUI;5rJ<34#94578B=Eg_Nef^O<4&~>at5W4yn9KdwmVR5IuF9H)9T*;JRKt=?qI5BG$r3}E_1eA1Te{+Tv zs!3$4X59UG5-Q*bsP>))h2*SL^pf|Zce>%@JY;s6AQW;TlnhSzb*NZy9YEE#ot^N) zb6yI5iLqTJi@ymsIu2E;lme(2%YhiXD&PHQLfs$$Vk%}kd|XM)>n(O|1&234Z1Pt< zi3o1p1SEEGGNAx%v*w_ciua$VA7)5bQy|?|N0p#3%m|OwyncMBtMxywcc+rrsK(!j2vSo2kM<$=?)|^lUxXwn_VuXT z8$v~%KmYJQuLrrnwtxE%MWj1X|HB0}LA7f)y522W-LJ&seKqypSHUiwSFtHj8{WHB z!$+Wo5xx;Z!E_bGg^C$nkXnHF6_ox6PXLmF6Hu8c0eGurr=z0WZMNyY#LxrmB$-gEkB!H$YXk~XY{+$OO z0gj`U>n5J*4;w!teWw7Pn*|`aHi7IY>ea`S=`Y@`#kfRj| zXjLO_g$41+Yez^O*%N)A-GimsyvbJcegO=@I|fF=aC>TvkzR^8*~Hcx)qVW*pS@@y zI2uAF$OSalDw3fe zTyWrtgTgF7y~*8W#q#R^J|;?Swjg1BXqOMqp>+s3Wl*K_9981A1Mtd#k&;>;nH0I_ zO8m3loPj(#k6j;bkER@C#ckfYl^;QnVzui~ed~bE*>jK`P($2E{&(rS`85GJhF6b( zV*r2vIG)<{k7dxd51hY_z3S?_J6srb2~u*KgkHGE73;|FnAkF#yZGAAfkAUj=eDZbtn=#Kk~bKX9o5S zX#n=8KZdF~5mt@;ramw9$Tq6HNGa763j6?I5)&wM#QXZe=k7CT1~1JYPpBz(hup4M zC13poz35tu1hshbXM4i|VJ&QU+PN;G=c54KM`1&$Y@9v03@sS;Po-DN+mrHz{Kw5` zm$eZmx61E z5_yotRpZ0ccjwaM%033f+CBf?(VTGiK6b@L z+Jxk9c(qBe-FpO5>DID9HqAc)p%gJ7>~@5I>+nCXRHhU_U`XNJO&io^r9fKi$w$ZUM;40KxuWYkFxN7MQo+MeZo0oQ6%j)U zRpW8M$=wc^{B{I#H=5NeqtP(1O~Bd@pcc*r%`!}NeK%NxNyYfAS-4?|7!ZyjX;3$q z+#UPZtkVLT*K8DLr4flI^?--iqy$40UhtY6!7n>iYEAC#x#=IDpo;0x{w+KrOQ1pB zt4l0;U5~;&0Bip2q^2tFy(d9qeG;kjFF`Q?=6)$JG^!u!Kl+15_UZYND5!nF&S!F6 zJ$?Pj$-IZYlOxZdJlF$JZe>#SpAQ3V%)F%wa*vppn?{8zU;Hy!SCc)VhQLcmC)n@Y zBnTqu$Dpn%1j2cvebPDKd3_diL$VDsZUdub4(2U-bUV(XhWUy)raJ%rG>Kk4ocWy z8fau+se{CyndDw)3A=NmD>b2*OxKieT#IhBIwlH3=d0%lItoQQITC~z6iCqJxPxkX zOG7bCa3eT)QQ2=(U$!&HA%V(#KNx3wNz3#?pd9Dn0k%i9JQfwb4r*chpMQz)KxI;n z99dq)UqYMR9|{(Sci&23OyTZX z^~wXkMqttkf|cbQ4y|AZPO?XkQv2tcWT=t!L`dCt+oT1ABXCWGoNXJF<)-z=6taC} zo>(zx#sY|v{UeVwVC*<3w}COqgA!ERH2ts;djL(u(Oh?;684om-`_(nJ({f>VHUpH zI^8n|G0({i4gbgtRTr?Kh*0_kh4k_O!{_03hUnlD2-<#v)Xo&Pb#$zW@(A7qp7}5@ z)-BFnS3{-qnGIi{TfO$jK}ZCh%hx~g2j7P(ov0FXCcIpcJ5U&lGF1^0Yx;KxuoFSg_b>|4u)CIQ9 zna%C7IY&g5SweA_$yFRvp6CN|-Tsh<=s_T``{=iVV@?%)0F6q=66Kc(Y#I>bLRBgp zuOM>)K0pBcA3sBS)m{OPugzw_*`eKb{2%_0jwqEcP-SOyJ;$$p7#TX!pnR$HIk7~X zhQN>>2ieO+I#?NqzeIPo(J)KYA$^^Y+Nses9Hs}MrxiqHHF<%p~ zQqZ;7v3l9UAL^p8;{aT)M^U}@Ber)F$AfF=Zh}BFjV|%Sa3^CBXtqQ4^WS&NMD@#G z2e_FdxLz#NsXCm;SAlK&aHc1~!cX4%wRFSG?~w7>;P$jBS>4+cw%a>Xebwa0raKlL^(4XqfTebk#ZnX5vi|Cw(8PJ_i2yt*g zN`v0TZ=Ug(&6{ELcSA*t*3keCy=drm1EerdRA@r6BXmDK6q+COVPywQt~iW=u`#E6 zjscP|^F4FqC+Xs1r^CPP`fR2$c{^nT(b7L7yo#LZH zmY`nNm~UDYGz4q3I2%$Jv2IDcY(ggPHbfoTP*};5{B)U!AN+JZ=*l2L*o^#?3DFg_ zz>^^^+fp^hYCuWWGdtF!g6v`%U}qp@ueRSwi`|9rLY#ZmjoUmyfa&nqw}S7jp`r+~ zczFJ)B!Gcl7^wtRLR(Z6U?B%%euEC^gi!PLEl^GSCGhp~D>-7V62$uqr+SLR09mW& z@lXXn<|O2DnS+cTRdQ+_Jn^e zq9i$Ci92%w=;g+8k5-OFv;U6jh{I7cbW#pFd?%CL;E3GmVhU<7tw+IOCODgjohJN! zNDe||(IGj2`@MrRZNM9*{`m7NwaFe&_E9(}Xn?bIa9hOC%|trk6roE)()uUdpjHTU zJq0pAj{P}4=Me;q?B8U!-CGcGK}DFt&}pI^L=R9ZT+q}{0R;RB2Jk3!{tp~zafF*2 zeT}&Q9B)lPoD$UQCt8evWm1OkK`?m6uKb!lH##PWgV5Yza2*s()!Yn77@0DGcvN{I zG`p+VC0>Z#Glz_4@3w)yXU0SIBVaj~bIGwvpp|XcMk^p_2nsD;!WPEnrAjnH5|C33 zpmFm&`1 zK&PX}Z6m9JE5DSoU@rBog?+A7m)A7Q(ed(d2smD8AKHKL5w6`IfJ2@Ta16NgL@atE zuSg)|?fj&>uMT|8tplgWBLKx1QJH^dL;&lk?tpIeyb!|u8u>VVsQqYtr1PP@(Y-zA ziU$;&;-K=;Pf_-XTy`ZwbGv*XgS~Lj01mObdv}2dM$FrI@rYDciEFA5yGNf|LAiT@ zvrYoUZI`c0&iDaTQsU)uUoDdT$X8|{o{5?lkDp^G1e(E#Dec#G%=m$vXz9avuysErKRY`ci>CeJ zVJRG^BGT5Hu)7(DY9YOFcSwPd!9P#-LojF{7Q+XGDN3jwDGGYi*{3um^g`H3d$-BR zPCOn>pT>K8vu+k>ax25}PyA2>bI*cyvJ*tBzL!upOb=?#{R&2kkrx*nQXmWz>TG{> z4FXaB_v74&l1P4&&YoIopTNIv1I)#3Xe*o=au|H(VIDg_17{E-a@P$=wQAmTfrI9r zfRO0XBXpoHhZEt-BsdFERS)F`I-YgA*!jdk4$G{5tLCXB@JjG2*{4*geaN>iiu8Xd zhbz*7!npUZebLV?D=RaHJhaHgtJW z51r>aDyQd;+PIZl`Z8VgI0EE) z*P-8NNltD1V}Ra~&Y-&I~Tt?**kL z1_Lkmlrddfxr-A4?=pwwJ%E3P7N|%&Dt?4YR1d8xF+X)`>SlqGR~^9{+>(6@rZy;d z8+0@kz3fxaTD}7ZbqRtZX3OW?<7Z`|nd!Yx_AdT1Os%^G_1B$};W)yk2S}`;hp~Q&tgAm{B+W>oo5U(0WCgVMCy>8^q}oDnoe!~3M13ODWfSzR z*$B>QwSh09f*ULxLMxyK&|_GRsEPoyQUY2KX<*l2gNcg37L@i5;LCY6%VrC(>%LCk z-h?h@Up81U0>R<668zM0UY~P_O1!;ZAQvivvs3Z`Q)>q`e;cq8eG_eKmd*m@Zh}}C zP6v{0gA+J+C?qI*^i|(53$@Kx#!G|UA-;4{Xh<^H zv~&d-nQ8lLyFF^T6e>StLZx1B<6x;jM~S9FUPH4AtdPb0!Eki2rF9knjipAxf1fof zupQ2k?S+y&i&+JtBX`%fEOvr8`f0BIpCeZFa&erM-{(|y9QVUvnu`*yO-iCm$EU8= zPk}3Yfuj6rYNtAZLwVcLI=hMfz~zp%&kb8$hrd3H>`(@vAZ{l@8k zdIlN80i)<}R*(+%EIzn=v>eJ(pSuFD*DY0f)za8XyoYLU6Jc=n9;;kAB&)1!iVP$N zCNcluxzBt4bYc&0;=>{sA@%DPtMNTCs0<9L1G3l-<*q-rbjYf{Jvxc5+W9WzVyE=Z z-rZ+mZq5>}joe=J7}kS|u|UjHm-+%BUm_c3#)PYx*~ho#Yrh^jbK_g+2G+(dmgN}G z!i7%%@JeAH|EPcL_|XEbv$weZUwu6LrY-;56Zaa2T&RwS?q9DI=lEs>(<+~3TO%i8^s+v#pal}1Rz~fgL zE{m<-^eb2`ZGZ=7c5R3wXqnL2AQ=uLOPzB2=YFd<)-^PSri)^h+yAxfm+z^{CuGagT#1wMccl91mak0ZGGH`I6?l_xSO0PTM4%NYY1m8t$l`!>!kt zl_O-`>&jCr34f>hsOUu3`H?Nm5+hOF>hQvFE=6LR91oTEh0fhQiL!kveVaDT&=&_Rf>cCZv$LJe2fAieLyYkwOB>69GpHq%Z z(j9WH{q`opY;Qi+O8ur69-F(K+jVeV<#79V=0KSyHT|q_}VXiRt3?F?WNW z?qj~&r-;rQpDjiV7tXctbDVvD=TgyF#O{K%K~1|kC|_pJw;tdK%6{y=qmx|DZLFqn zXZexxs23iVmPtJLxBGbc#<}NB!pND0BxebYkr>toJ5{F9xw}E~1p>0WC5giQ%^E?gH{btLV^hK#qHj)mOGjchR7_%gNj``R)WwS)M{jFc(vw+q?th+FTp zO!1grhzx4GzUy$nFsJld9+8i;@9Y+{+(}b=L;`%i?TU}^TpU%IcYdAICmEdjOSMT- zI;4r0rN}o~-OT%7*)qN5yJ9)(5AXQFp?!8$e$ei#f4RL9m}I1N=d)WYS8;9p>zwGm zqc`cITl6-yguNG8<-komLNeXBdCfbmH5<8kwR}D}J>Iq}_(`Vkxkd z!S3R^l-mJey{}gr-O78;o4-=v?K-&MP3^B1SC?0=Y6|80y5p*34~KB*p|4}V_D?CR zt03-S;ByX4^KeJf^$- z;T5y=$EKUPUaop6WO3_>&JLaH4}8s3%q~2(ZqrrT{w%yhxBEfW2c92)UE5l|(e9?3 zR%w>PL-(wm9}4$wTem(d>cyKI=f(Ew-+OtF_q~RZz;{8Xjf&C9Z_mGAo$}A$(IMPW zz4m$Nr(Hv4L)JqZy_V0e(yh+>`u^%jnY17`KIZAG9k1`dUjI$XgX@M&rBv^#(HLi% z=dYY89jwa2!GPuD!?98qHw}S>lz1bhC7GFc756LXSDc>26P{;xZXT8Q5PuPOBl1S_ z0UfMioag|~R_~*Ps0-)7#sR?r`2opfqYkk{aaok*tPL2&>E+Wr(^3D6y0;2zE9%xp zX=!P32v)R6LU1oG1%g{~hvM#9oC3v)yK8WFcWH2Uic{RJ=t;l5_mSuS&(*m)7d)`W znsdyVWU?~!ecy=nNcPBlXRH>^70(qll-z6v>M1EtNjcUVR~QFvBf}13*_GUB8EFY< z3uZiuiHhTtsFa+ha4nQAeD_uM0sB-_>iLDnRcu;pwrtiZuTuip2#g14--~5ziWn$z zDigsc;;5jhrX7gM<>D)M=3#s>Gchqvg8hJ*4$u9{OeBR;a4@D4Fb1hf^T`tzv}@sLOX=Q31Vn zopuRtHBqr%1uBI+v8_yMy||o|qGt741t&cjZF(K6QnkXl_=fa`B-aYp@Eb(Zn#h1L zq70-A?UoO^KWuAG1XqbYsxpK0a`hs9yLfA?DP@&*D)>$y*nG9=VmD{`zzQVfB{U^! z_z+1r`O_=nB7!#}li2Em1mP*$3H!6Tp+T(Sl)2-Y%D~Jr$6DR;l^eSNd!lBd zb7OPFWeS>|_0@IQ3nYzyN9QIu7jHt2XB>}r(sv?;2ajitl3lM|^KP6UG#(Id@-Rv; zWP_1{2O|$6tp}+GrNFv{z`{KF@eH~2zhlQ^mrR^YhdN`;loezZVikzCY_>VJv$jzy zvnx0&+AC%P4E%E*>6Z}Cd-szIl5?dC!*8EFM?JK^RbHN4Q~Z7Npz^?fGkdx7;QG+* z^V_HOiL7iwr(1h+AM>&O(d|J9Z306B=gf$lnuWZI`b;X?;)~uFeHIWm^_}F|A=aPh zspy9s-khF^Ocq$xUe#{ZU;Fv9nzPCGTOlsk^OSg$%`v))3q@5$1x3vXw)D4U=^+gv zOh3u=pzU;?vk9{(rOIkqkug6F2GT>s27e5ag?f))nQ^lG*=*ai+%nZ z@k})uq}`GoSnf~iR{$-5j^vZ&oh8d8(>Ge|OPiTv`Qr{^cjKPoFhRc3$O;xR{m_Ae z%x&0N?6-D{A&W(efc^XZp#3~6>$xLM_Tk$NE|brMrED#4R3^t&I=@IXiE$@dWNF6z zrKV>%gfuvFGPepzFq?Uf8CQ6oG+HTIX{KhAu`cMv_$0PGZFA&GM?E zJTo($Z#~#_JR!^k&&3njGsshd-kZL)grme&t-VsdtV4H8XIiaPqqW`D=cDJ^gwNnp zxr?+*zso6xJ?2XBZPBUugk=$T2v732GbtktqqWALc4=w2sbOh?B%7o}0wm5&jf*AC ze?vB7rtq7@7TCNw?)M^2bvh3%KUtUC<<;YTL>vqp%=G#A@O0*owIu-l0M_!Ic+6?Y zn9`eh<+ANO;!5or=_=-mo_Qs>IBm1M`kXfz6)azzS;E8U*>Z)pA={mzVCFPFmLVg= zpgQ0WP?EW|Q0gCD^?`_7stSji!zdHmg{aKnZ&rs=drG3@- zVtlUr;J5R6(53WHm#fUq8I$QFKl?U|^K-%Bs7zyUd$xl5udV~ z@<~Ti(l@(}=Ye;7@5C^nWNQ3MUNdL4C*wAGiFt*CI)nEg-wAaHjS>0Y$P6%_f>AP~ zvs7eDWH}X5u;LJEJ(l}&8oF-2ed#Y;lhGUM z$bt zx$6CZkVOP+LSl0AjGrFe3}4l{kcyJzAS|ktl9JR0h~9r&1|q~sw0Ub7VK=@d9VNrE z+UmUb3?HppR(&(%!4ztPk2zQ}M$czAH#b7_^Yi-fo^Lk~cob?MPFW}N^B-jePKA!H z?$gW3$;rpzVj&-UC#TCbcvd6@9VG@aT;*ss&-W|TB^yicH@u!ghE}w0JWJ?hocD8x zlKnx9xsJL^Gp-fOjJ4KiUx|HyJ+@qZiU#UROUS@+@iisIu85Q?_Na$HmW%`b@$>U@ z=(P+*;_E?Rss?E&b%{OxF}yW*mpMoyOh598akB4VJsE;iN;L_=1Ev;zhv2A2o9Chc zh6hUP$8|TRjPE zuNAb|g>SN4ZvOdP2v631RD`eaM_Y4q^NfkIwOoU4cp`iCW5+=LWK#THFKGcNu|Lsx zx!%0%O(B!2b$FS81y3n`lB!G*Z3E@%Pw49W*mxyvmGm1f`?L`pYWCsUOk`%m$ZXh7;_-b}&Za5bre@-a?} ziu@Rkx-~UWX;k?T9{OGDN$vop?yMlJQF`qIdAW15=-Sv}8n-{iU&&-l(ipYIQdlH+ z6Buum3hzj_TkA|^cGBo&Ef~4A>t;%NPmLxWjfXX9@mvGpJ&2MqOFszJh5?JF;fdN= z{S%s{?XPCdc7(^?QSd8m>tskT1!?AfJFNY{YgLb0GCoA5^9?Sav-s_5kZ2CN)mXAxKc)Ek?Fb;BX)jDHnz?)oh82S0@FMN1& zmMPOG43pA_?5Mnab!pmJ)iLJv3E$l$>mu&}@u)^)p-u9rDX~4;g+a3Q+6+r8_aooO zrd(~ksV4`Ftak`M#O3D8qY_#*YSe)a;5y(Mr0UL-wZ0(@r$rcOr4Q?(n^p}c6U)X$_#eywHoOG~bER|w%>4zDb?WEGR=4iCJO|Nocv)JVdUIi(j+{XQYqtU$mpAIYHOx{tD zmclh*_vhw#&vR>(O1vw7O!Qz4?}+_<2-s2KLy|d~O3r(#MtP^uIc4vHJu1gTjwJJT z#^tb^80|un@HuU`5bStRXFaKsMLR%QC_juZcb#I$@c$42;37)AVqQj1v910&rU#um zr8(47KBh#VHpe=GOrjF}8uRpFI-Gnv2b1+>`A`?hMa+HwJ&w3zfDMntEj_6kvPny@bd1z4Lkoi1;GYoC>mV!54#1Sl<* zjm4RdgMtSOw2!X>3*V48kS~*Ks!nD@8k#O=G{|5cw9igwf+v2%6|?fp960FwHT|Tw z(sSD9S>JICnYU0uR+L2Bzwq5wT1mTrR~nCnpGg#0#(t|EX|S175}gS(z6ogB0#0f7 zZhOsDsdEeSPfn9*Gc0^&pY+4X2O?G)3#=LJ_Mra+dn_1*@1R)F5lw{BPPTcPXwy9_ zlShHIPOJMJgE$D#_d-A^Zk0b9eUrh^t>H%w>fQm z0NHQ4=QUVYtKhGOAUTIbdN#6#Fj{|2+phtVP%f1kvhi z?nj0gC1+Y94Mg1IYT!_g_GA}Pk$Eua3zKd*(wv_dF@wM3z8*&v661j(Y|8*A*Z-5d z;yW1<<}_^dGGK^Hbv~_ua;|>@S~W%@3)EVkBYR8Du+OnCcf=$v_fLpffTl}!>c)3vC%(7 zCqQDT@r^88K}0Mx^f89%8eyLnGR(WW$^yfLLW4BIb;FR*#-z*cv z(}jJV&wUk82k>?_#GW&~h3l`6^;R)NwzPjg>DR3#B}7z>P|y7np6Dyz{utzbVdY3O z=_jlmSLL=DS~6+Vd?2l=bHzN1k09&HEnNk5jFxk5p z3aPpL8*%>X_HLYM=G^lM1NudYV2EbiGR}G5&6^~-4%ko8dLLRx-9Zgs7a?*TrIny5Kom1J zL!F(ZH3WZ{v+IH&pnS7QLq2c}Ef&3WQLOH(YO9WjQy>ndv+)^q;!%uwwRMhtwX+CX zV5A?2kRO2VF3dpEhmP_phpLks6<-D^>KU7j(M}RAq2}vGVRc5c-g_Z(}6jl0A=t6Lz<`9)HVU(+310O!!kVUi` z$dy%F8FE?vlssBp$gTc*R2{s~U9krA?S{u($28s6oKE@4LZr^nv#uAj>gRv|85v2d z-A(5^Wvzt_%9B#@f}`ifg=yb!e6ykJ$61>Lk;@cwgaN-RXwx!c?5PyG$IvE-D}F%{ znJI|)vK$vG3%~na2`iV=CU7ZJ)>s;N*&Eo7S~j@+nw1 z`g;J)TM7+2B^5>#-PpM5iT2`rcgmvjuqC>FHKhBG{w_20l0z81Ueac0 z^fe1;oah&nU3K=>T^Gb`3W_QVru{benJJ^Vq+{@q?i(xpH)8==SS^y{ej-xqRP{wU zgtpFbqt|?WZ`OEteR~E;Oc8}onTRLUiEW){`e$volYRzsjlX*|oyoewkW&HSI4)FA zpFf7N0BvzeDb<0~IJ!a}GgQ!{9lt|*jAP6C7rAZ*8NmC>gV8G?-C|%dTDXN*U|Zp1 zyI%NSJJjm&sF{a{m%WA8V^(s*J_=~4Q1EJx>6>i67D#2NOR2t)HxP3Q#{miu^Gbji zGR|PdjpL35>V#8kbn ze(^Khs_}%(2LvO=o}26bW%1zV;Rz|Z|3Lhn?&r?5%ORW+l1leW@lrtW0grO1X8n+G z$(cPY6-insi@`Raevn+9WqUr>K~xWRoZ-3-y55W>ND53g+lBo|8#H zIy{=1rJ@`yOPNBjrc|zyKV@kIQ3?A2t$L=hg8BrC#pprb(cJ@oQif@U(G*GR;gy<0>$v?;e+`z=@c28O-M|Bte9Ji?OXe|d0>%(I|@1z0f>9u+ax_yPIX1b`l&Vz|59n4 zd#`R&A+<1OR>0;#aZeJ__00ECpE6v1Ltx*mauD;UgW|VoPYXpHEcx&%ik>7&VYEP| zPw(!Nu~w29w32yZ+y^2Hf-+O7C@S0Bj+=4AI^x>!BZz<(G2xy)v5@^u)jB1u#|?Gj zyu^%oftiV-TIoJ)F)t|#agA7-RZ;W^-cksOVqa3cX&@+c>fZo%x4f+o%8q;vDu!3x zEMIa7HxS>QLp5Xu2D_*}YE-EbA*aV%+e0a2IicVtfDfo1tL9MbIG4&v1f>B+RS zv0VVexua<`J@D-rI)EMOO5Xjy5lrg17JJdSXRP#cL1G~Dupl8+utZY?*EK{$7lbJ< zrq?WAa0`?+qZ8CuN&CCNrg#Q&yndL;Q+v$psr+>Sh^J{+gca&tK`aW~?*QNCnC1VM z>kizXNzHWwS+t|LnEI~^6$5Txhm9^SP90Cx%VT_zHk3Cl_G-D|SC%X}6Cm1rpgj;4 z!KSDb*T-v%P2ks}W&E*DISh}?lZ?m9-iCSb*C-yv>|I-4sh-QioGXh4sfhc@LiPTQ zhY#TD=V`E1q|=OoR3;f(vL_{uklYy4PA*KwL6kVc3L>&MJ?Z3csC5FnwystRRruAV zMWFaL`UBtYW{l-5eJ1^=vjWjIR=tOKiE5>L1;;d%gXv{pdeJPqQn?2xvs78NXfu)Ly&_{wRV0DgJac{Km+SxXclo1&;!>NUge8GS76<^XXN|6W%w#=>nj zBj=)Z-+E5K!wunt=k;@G<&@oQX77D@{rS3o9+#GHKu*9@v^4jx_Xva7>g^k_ z=bEL{*p(f!;00&c&ki~!-6FhBg0o?Vzj*2k-t$iwFMlV4KYA_PjdFfmvdi$dx;k02 zs{iD{)ObTj-vbjOs=C=j4~`&d_?`X96Z0d#dl1>Mm9tok|#`JQ*PL+F@U|IGP5!>De)VcIY!Zt1firmxsxB6exeqP6IbT$}M%j{rbbZ&3bJ6w)a@X;^8<60{t z6GnZNp07yv8RqdO!AnI7GAq&tsI-q2FGc_zVyj#zg0QzJVj;shM26eI3Ki+l{HlC1 ze-OL&S^iOKyyUh504GBo2TW^yG>s&$r_v>AG7=)48A3lN0#)6LE%U;*O85x-AdM&pWm>GeD7sm6JXJg!Ci zjNi%d3P}XmR&f&eu`7?U+fHOy`yJgTsip-KVL0bXwD59M z=c{;^0znVs7BJm6%E%AiA4X&;HHgp%J;PSH8!My(-Pk4;VBtN>anuT_93}ut-x?%U zf!pFG$v2fn`a`!ET8EyGM}Aa34x2H-8TJdp!4Vq1zgnzr$(AbZ))Le|%$P+8Ta~f7 z^j;Q~k75qdM!|8tX{BOXR674G{=u(N2}%>8!PxgI z96WeEPFbY`9X^x8eqb==mKgmf%_RZ-+| zew;Oel7)!QeSB>)u`XPsmF9_ISVCChfo}!7#o!nyNV9dF!3Mt?Byo5oYdxOpRH&qQ znqPANYLB zxFFMcBs7ofqi)8xj^Zfb_jy$%x*2Cfxx&2|+IkC4e8?GZh7m0uLHHQZRk!BtTu`t? zM8h?Y`7iQlDM_x6i#5QJKS(W#vfe=KwhE;h=*WIqt1MQAdUx@g{LK8pA6?ifl)PZn zHAAi+iq(iwgj`72NCkGD<&(oc;>x*^Gd?sVBHtz5nP$R{5oDO!XT~uFQ{@FWtEdK} zR?Ez8ZF`Y!o+i8ez11}>>^tj`ZciMxEP^|EFS!)-U2d)ikDR&%mtSrsbhDawmMVXL zGnp@MOl9%ddb(M7Xv&M!zYmsyDYEIoj3U`p)0EE3x}Da#IKnihcT~cfPS4Yem*T^c zhyWC3M9U<}hH{Zv+&(+AYE1X7&N?Rb35}`TWEe@gKK@St_RLSw$!k+5M61cJM$N2? zvn7hi896+J+^12rRS@vqn>o1?F}g3IygnrEDxosxB*qgS0Rxf5)7S497yRWmqCaPU z4o;D<=FQ!C$9Tj%x)qXbSuAy=)S9W&1>7y*m%ug zvtz8>`WYNvYu69P5?3^ig>(nZ?^X9i`&d;pYTc&Ezx4NZKF!vHNxTO1mi2u`e*1RZ z%-`y{-f-D2sy6N`EdurMcFtC2%!bXj(#I&kIBnlA?k?j0;~woXA&_$6(mJoCGK6 zHkLG03r*WLR8q*EHMOjvN_1XKQYs)elYNuKo(k0Inm_@@=k!b9T2z$Ji`&1p;aoZ^N>+rqp^{_6!QdESkXE!TA3 z<~2)!c`b{-azD_B8sxL|1k@JnzrG05GkHE0ORTuwAa^eLCOs9}_*`W2tz49DF>&wu zr7`i`f~Tp`b-Z_u!PX8-q2SZHEvA<8Bk^`ytH}Hn@>TU(Q!3jHPOWJ_=?~CvltbtY zp~)39{o=)b@n@T+c@nQmOy4YjYS@tl6BHkm=eJ364)=REof#t~ltpMekdy2X{}KzE zu1}fEC{0 zX*EBv_0E&Bm_v6exHs09#{^Hxyxn+>mx@x%NFS1jnHJ3ss~=>uc*{;xg%0K)&e~lC z{=`q;EIuH}7o|{D{Sv`72x$88MNyko?QHfA*<-A|x6 zk{*=vda!4peSYul80xOeJ4_Lm@(wGxOaOC~q8O%ypJi4A16eA*{S8j?v zyC*fkZfvt?PYc|hwVboxo-8Eso+%#u=(0JsY}4M|)q&_yK4taSgj1{WPL|783$)~X-P+jqJ{9s8%Bjz*+yjw z$nLK={Q9ySt=+QW;QaYbWBGzK=>)sQ1q4om7ag)fi=C3&fWUuf$wHYA&G%SgSf2D+__KuI?Je>20t#olfD8ddL z@^H~9zpMYkE`^{Is%xQIao_I|N_=$KwBz|gy#t5s;)?+bXb@1aO~G8|7a%VK#L6FTbL!swmfZ~eomZ7bw)mjWj9 z?Wty;8r>IvA|;2fa$OVwlo4pl-KA3$9Y~lH5wN6!DPdMRcOg4W#fQ>YwkXj-HyBCP z^=wSy=MGv;*?^@v{?GYjr9Tg1B1DCZWCqpBX!I7iHBzOmB|%4BYUp2y@^rywC#rn_ zFxNP@2pP7AY$(XOpM@^18o9R%vJ^e-!uF@y-+&CDyi$%^Owf;5{SluBTe_eqo+#M^ zceFxlWGfR{f3(lA?DP#d-`E`brtjcHX!&x1DstJaG0TR{`SZ(cOB7MV*x4(qvKH&% zqU(2MwfEBA>Zh#c5t*WDhv%gMZUTS&vPf#L@xk(0j{U5`I*ywg%RiT#w_Y6{@w$vK zDGxj!^ljZPn!SiQ`U1=j^1k5pv&Tfo<|F{VWJnuwR?&qmH?-}jX;-uz3<(2yDOVkP z5+}AQ<0qxGr?SY>2SZ8=zJ+~oCY;K>t!mT4A1y7$?Uvd6T$WNCAf(IZL~SOX0SW7` ziP&1ekYQ{GdbJ)o4o_g-{Jx%`Xpjk}fd8=_D)VG7WwmRaPAC34ms08n@IpdrQ;dp4 zhDO!wj+P!jEDHri@#GC`5+A!bdnQTJ%WUwtF~esjC;0U57Acor<=RBO`tNUttZ?(P zNjOWDlXSuCF$A7gm@A>0JFI;sqK_b(@*EruCTV^OoOWB=uRtAN^1Yuiuqqcc4d_

GvnjYuf&(CmuluFf^DE~rmzLNB7HhN#rAWA|T5($duwTs48qfif+hCO|Di3MWo z1*L%H>V=MpYM!J01KxcnClzwwzuBfBMsr{%w=C)NrXAJXtBz>Ns2EKjs}T8*4lP=n zNk=wCGb4b*4a=uMF=oKc2J6;9o%7-AX(jCJI_Kl>bQsH~q1-@>GEsUc0zdr`t4JVN z3G!u#vy%N&PZ~WYLry|$^fir5zS-FmDN-fZUE#7FmuViw;q^iD&Cn{BmfNVP>+?b0ilD-CbKSy1rM?k?opk z#zWA2ZIe2OW3czpV1HU<9tlbSC|F1Jg(C@Pe00| z`Qx&BIsn3Gab2VuBQ^=nz8HD+EZ)AyCs0|S;3s_-)irhB#09>$Z3E>^=YUnKDva(ib{ItnebT`HwO# zI$`%lc-8!(xB}xPZ^Xo|-N)(@WfVJeaeMtbtgKlL_mG}EZuOn&DG(oI$ppL_Ez&-= z4EO1RZ5s~j7#mjH+uIAHgf5HP-Bt|m$d=`jTNVf4#9-E~h#p6tPXIEZrHvOTa(K$H zPMEE(V%Yk$WkPmQ5!qmMUpHd`K;4(&Z4eD_Tn|}&OsO!GQfWrzz~QM{#eSiwj=eTT zpc>~tJ8M+=0UYsUiZxER2(|hspBVYnJ@w4X<9EXLRwkW4f1BZ(9-6xE*^f}th6P8| zukg-Kd{Ik-@ptciXb2rZr;Xw(J`=R~aejdt(6NTkPqY38V@gXXw=t0HDI!iCw_^jV zO2N99R3om{uNTQ1$`a^;=6{eqZP_sj_x#&EF|<9Qh`wwtS)>h1Mrn)ihWcQ=ITNAr}wCm)A3N@9tuw& zM8i5aqI1e!LNWy1G>yHkH~id&w>Dvpw4Z7AYYCT~g9t-KVwQus*?9QO-&_2}yuG}U zq4k)vfx>s5f2qp#Qn7&w>U5BJSsZP8 zunv#Paev~(2;oNQUPzR9bNS@Rv_xo>l}4Ex~LsaI{VP@DP2cawSxlt z!ZHeisb6Bxv?(%lAT511j-sd?NFpLs!hu&Gjx>% zGZV((?N3R*DV#`0;%;c808fhxNq|CK=3%Tub`>gnQXJgSyci5pKKK^}TlNb%FJZon zOp^KT1XeflR-L?Xp}HYjqoJCt4P>T$4;O$@*3chIV5PW>vs*^1Dpnc&B_=tcO{3?B zDAGywYM^dm9|+y& z(5l89V*vA)!r*QTvS~f6VQz^ZImiCH0(9_m?7w+vpr^k(@ zI9#a6+))V8{JTl9U&@Cnx zOcV=v3J!Y>eaTv(&L*7bj21@(6W<;+$2PLQs@~ft!IESJXQzF+%Ez;J=j@ZHG&6j7 zkLgdDRhDIV*ra>Q zJz1~dR?k(o|Dg#d5_vA}@}BYE6lr{c8BJn=Hl=b-VQ1vC;>bA`OsY`sE*DxRG5+b& zTF28`(X;kSoQ?&LU#VO3Uby89c=yy#x&wN;Tc{`*BN zd8Ld(HS5=BzsJ@PN-vY+EsGh8#&)MqvA1Vq#oBvFxwmq1<_3b2ueOanK2dFB2&f2WD1rd4P41Q>h{)n z^TO*==KWv0yPk~D007JR*saL3PK!>NQHHyHhVkd1uTR=bXzohv&Z@^?7c53q4#AP6 zHD-;Wu5Ud>)6{V34(mfnbdB=;O^n32zf{~bl)aVikeK{cfJZI3(B1#RB_peMR9l>=c#)sLt^Ol4-ilsS-8@soqe-NVpPu}!Y7zq~D3l~wdn zDm_rR`l{Cn%4lN#brNpF!3gSD9HZbB+^uM*qUcABTM~Wd+wMq%4W*r&Zfqw`& zAd`o{4bsYr;etvv4xEMwAM$N6)r?~QKmo{6VKC0FZ;LH&GxB=p?zZs{fH@$6A0`b* zK*80tnTbw_r!y_WJHCa1h|@6R)bVI*v@FSo5) zwBhh1MgFLD+y~xZzWtLB?CEfSl>zUsokSh$oCtr>C2?Nw=6ZTAfJ5Y44sBa#N^xJu zPn)i?9o9|D-)^3vOFZt@tmSU^zkTh4ac8k^7Q6q#$lw8X>YoQVK(O_dQn3?((zjnk z>ud2NN>cWe9SbL1oQt`oW*Zs4Rhq){Zop$0TUE$f+&Kf&dL|rP$M-Ls0nz2KywQZ$ zVH;cGACoS;i#CS9ZOtai_i;;?docV(OvmvYn&ou?(|@^tgty%O_BgazX)k-#bhC6g zak(CVN$NerswedI1rgrn+?%B5u^5i}=8@!Vx~P3JXkdVfb2PreXp)ccEIbn!Fb@;( z3h0w9CY7uT)R3{v@t4M9R|NLCXP^zMrkB-i{~;fOO0&BQaHpK?IEfPDptgcPK8I7m zackh3z4qa>6pI#vAI((1R@=krQtq~*0@fopf@t}Ku?~t2ld_JMhU5kH863gA18`i@ zo>8vxkt?!45gyw zTMW;{+L^BZvl7Sm4jgR5<7uh=s$ugVkJ}&7h3oX%@dO_@N`}zG(GeWL|GN7f*&}=x z``hg|OYcnGkL@Z29t(qo?$!fFVB7_IjxgxLhia75<_EHLqw0r^*$p@!i|5ld7rZ?& zf@ku_7BA-j90w9D1J0hqz1wE8%g`3@cNuv4r}_DwI9g;|E)U(P`&;WMOmd5HUb~ zXc0wt2VEvDQ1y-s{Tv?6=9oepf#R_fgt0fQlcuF4!aeYQ7_d*|7*<8tVCar_2%rbF z5?&ic@n8)6s5eb_2W%4Pd%Xf1L7$&aeFvAmzsK}~6Wpi7F}EIZo$;sJ@1~e_|Iru) zXL4^X!ZG8{`w4RRuV<9qVGEv>-c_*iG%n0aZ1)YndV5ND_}Fow)7RYSwuAh>P5YQ| z%z_<$EW6I9$WR>WJRJ7*f*3uoNzk+Fs}+Hf&`YNR)IXV*R;R}9m~MD(Xw1g$CfynS z-y06(L?L*aBPosR@|(ZmOaq9;PQR}^Tgfu)+W+Eb{9I%~8NyT73H^v|=aD1K-uZO1 zylh+Rt!7DzdTPvIBBt;wR7-$rwnUv@U2B%TXfQk?n^U30=&jYu>kqRpry4ymFDsmJarbh(F;A@Z2bP zh<24CGD%bqu~H0B!d-b*Fwb}qc$Axvpyl7Ih37AK)Z5Mnh?|0&5ITV4A0c$sve-6~6g^+v-{u!he=4mf zphbPR!Zh;p59PG>yUtQ!`IEp;Lkm;Y%JU&juETKuV5dPt&X7S$3Zhf|)$oHzg@y_a zP#UZ#ebQeBjNK>GyJbCThlTC7I~Nt;$@w#iGI;E514|&FXQ=uCvL_pfw$)b9QXD*V z1znJ;QeO3*U(NElr(L`4Nx`9y6>wy0>%K;*o`Z4c_Lg$j8!Gf>j<_{AtV6<{2rJfq zpJe>Rjq?QSd%#Rsw&5o>lCRg-8AHAk3;xf>o?g+eYTfE{>_S5F^VI%@z$GENFtHLuJpXv;88`{_S>eiTq1m930 zQop2!z?*V0sv3d7VbKVP1w-u@HdI8x5f_jfE83+&`V!g_fcwc^ESnN;B9(KQQ5v$8 z^HfzCvNXhOD*?_!CVIzmaYG;nC(g%IHYA2#r!6+}1CD(@cYQbtJp*s%Xc`TViq@^; z;;Mz}PK?vj^}p@lrTf3Y+oI2sc+Gp6-VqyVUiJy;n7Ps5=tIZ{6lWn?ZWo!JN2;FFMV3Cigc;m#CAS6sC?Ij~bp@P9 zI|!vPk(}{L5;OO?uyGkL#~Bb;5O#h0DZ|OM^Ofg7Od+J2BEryV+b8x59TcLZF@=?* z%!L@5D@|GA5!2^XKU?rRzD(jPZE7^ek=}v=Fq?eOVAlP&X1v$I@74+sw=Q!?++RI{ zBk)^-iUrGcrt~@kqz=TN5g5z35Vh)Vk+${ks1%m8IO_T?=)R)?1VJ)P9k{K83}I>9 zJKM^N{c54QwjJ_6K7LiPUgurzZ9iktBdTf_{dQkm_>`u-5~uHR4kAmK{Fwx{er`fM zpx8Y$jy{m9P31|kqhB{cP3Su!4$tDA}1^bw2d45weEAgu1gj{F&kDA`*GNTCnA9dlnPXJT?onyEfH0)?jJ zGq3J+dnWUY=lp`t(=O5k-hxWuX%&?kKt7avRPgDFy@@(-r^?Hr>$&sgLCF2XSa;ZX zx)+>LP}FO4Tp>Fw0bSzhe&vz{(<>;+uk-o(+lrxfdfReEYu;ZtvdjSb#z#Hrn`O6` z&ZlEL*$ph~(-uWPGcD`>rW{PcJ1l0xGlUbK%9Nl6?q-i^_Zd}izzFv_kpJ@v%_f}t z?;d_?H#6aM(B2<{2pCp)*US24 zH2l6P=USTzTeIs#+#N}Adj3Y>2Z#A#T9NMrRXuE|u_=C*I{o@Y@aoU$MVv1Cxe!{b zyH!9Kc z-}wKxTxO5+YYWPt4@zc6PE^7Q)B>?8Q;EcAvhlaX;bVsG1EW$zPY2 z2t3N+M^gNNTWuQK!bIL4z;sU?5uo%f#drw}7KX8>!~*!b?Y-+%mwZfjX^@T>x{gt}4n5T?I7RI46z;;bK*E&xNCG5XZMkQjkd zJ%b;j9+qr7vjhpRba80uRXDdi;jFGRQX% zVC&(PIDyzC>%Z_*tGihYJ_y^flwD01sb6$&f`=s*Yf8kRQs=qnN6(Ms;@D8KS<_I$ zY&YGCw=54(^K1%Tsr2_u=q)AwccwXB|J;#Kr1gE?J03SStS9m;?%o9@dBwg|UD^Tz zq=P_SDZ|<=OB(dD-&t5vKlvVLuc6{!>LoB6t$Nl*n&Q!s^Z8 z&Q0*r^ry9^Serf*Vt@maRm}H7iZ?C*2N?&PDMWQu9ad#ZhqOYSKgWN>ge}yRq;omw zDW-fJ3&a!v%-6thd|-~GDDk&xm|PvQD)>^4ITfhY|7}4Oy;>TF_m=LukJ4^90nPTk zOh=Xl1+P9bZcs_Z3=WiVT*-cIypAQ)E&DU;D{&A?^}zv_x7mNxKo5;SP?I~BsWWVT zwmw#K2W}Trc4<5PGR6ZpouOE>MztimQHL zBrZ*Tbt0GfQ|AQFZ1&pk{Q*Suztjluke26_RvRN$8Ounf6-JT4&sY49l-j>|@DiUb z&DnsK++d=<+W5dQ_X4a$UZzgu$*}H-D`QwnaVOT|(Uf((6I*#2R1VCA9jyx{D70cy z>&x~`UTaE?p%w{xa)|$tWQ1D`obfD>$0evC3)Q^p%ZvG-sLE}Iz?kHZ@spoCj3RbW zi4q>}+!}VV(5z?%b4_yM%Fkt@1-i90>A@>pq){?b9*Zde5uUkYHH&PLw>DuV09CPx zXjvEpnj*QLDH?^zh{Y3!@e^W?GD?dpnv7JTMEPUgxDyW*zOM}|ev;2_Zx>?a4nHw% z4&C2?DwWS^W1`T>4UiR!vpKiWk%$gbiyvZfqZi_u5Q0F=bFT$V`_~&GxyaQ?i83Ik znm{sI|G-T6$rX&y)hj$KNgUHG0efIvYy6i7r4=b1dmVG{r+tR)w6uWS8+SNn zqxVL9k97f?yv^1|i1Qxb_4{UH6`*&?*f}g3Apj#8lkEML3RNEBa*$P&+FTPVr}4FyD0vPy?-9l{h{4IUM)D(_1{Ia zjJRtClHCV68NTDYEwii)?*)Tkv-`%uRkzi0%)z!lC!S~79XoEq43UzYw(x*(NqvH@ z-jzxljaYV(d7o3_)EQ0K*B=9;YSL{vxt_6841i6&GD_n2l%laAU~*8dVXJMm1%Wmz ziS~zu6hdR{Qy3h!X>O(2=`T9lN;<*D&FnoF<6q1j?Ygt9%;0ykLw|g{qp-+d>>cOx%B*mqp*V`?jqo&kIITFZc6*ScC9BdY$DAGd-Yp ziqSsYP$Q^qgDnJ~o4ng`#6;HWRGySTM+=xQ`y1ne4`{UV*EnF=0gV+bB9`TGOrCi5 zugv`&=;!_qACp~%8QM-32)I|BPC-dJ_ZP#JkIw^XcXEo4918X$PH<;)9b4dXr1t)e z-B{Ej3*#wWuAZ=E=UM2qNb%F_lK$6c_SImc>cr7>|KWL@i!!3_I)>N>^Y;SZ6=e8F zvpHg=1MDt63cifwcDtDVI#FQD_b>RgrbQ&V;3Fjx6?)_xwm92HiWPBe_;=ZuAAbne zdfG4uj}vq}UdS-$c#l2ADLn7g&+Gj;2>$07_xAca^uf_pe;Cn?AoF$ejF0+YERza_ zl06lsdmZSyt!8-BlnJ`wzd1?W?@*z2x=@rLd%Ep>c`7c<>DR{1Lcapm$<>FXWo! zmZ0zS*4O9pe2~2Uc2p4DG|QZ=#n6S$qX#y^27@0Gj^``XB`<#9GM1Ajw4=J*rF8iQm-2N*yh}PxUNp{}|xpj0QSj^AUDkTi$&hX?}V9BlOSW zw{GCJrVGpoc+%~+dZnL<)lIz0#nE{2RwW#@i9&tteA5uG z$?J)?0$b{h`h~zZ;?EiX2U%|!7FD>0jf#{ANTYN+G}0}CbPUps0wdjB(%mpL1Jd2y z9gc+3-67qbXR-G_-*=ts{G0{MtXa%@=e_g6E9Mk6S}**|EHOFr(D!c7^wsgT#xIs?!o`z-Ilg{z=uov$BAA@QrE+WOq; zn5yjE1JRJy%%oPsL`uCWH}kEqRwrSA3SGx?Ot5(}k^bh#AhkD@DwjUIjpr7=xWN<6 zeS$W`okLx)*ry|Tf5(Vc^s)tpWQ({yN%Ef^9I3WUZa6;9qJ{){dyNRxjpwU zqz`@|+B%r1wfmT}i(Lp1tz-=KnHwV0rCX89sFLu{Xa5?ZB~~`UA1|YHUyBWu4u7mZ zbmIxj?h8xCRK-VY=f=aeTj%aom=r?0ewYePvBC7M@wEe1qQ=+HtcRV_>&LV~VDSxj zIUtE-@dF@9D!BwL(j(+_G5FUbIH}ek;d^R4*k?{0BXC<9jEG^1e>Wu+?I^c1?CZ9w z{Zvm$1yAOO3vTz9{0rENd+8WaZSEZpZ+!M_-$~ZMB9c&AvA;1cstI9hrXL*)C);sY zr*;$-FYyjdZKD?w(oE=s2F9JVd;LPa>%$4A2xaQ`e%Sl^^mx_L0aW5@)ja+x!)n|sZ@I~fln36y z7V4q*3UI33qWA@C>PsiZ0&Yibmnoe8DBguPPV3+4I&Dr?QjgR&lJLz_R6hBB;D5Dj z7387RymF7;24zRox6VA%VY5wIfh^Z!%z*i-?guL#sr^=HngspEfHYk27Dt&*D8#iZfPC)!h*GXGf02(lU>7z0Vs|gaXp0oD#j5IAc}(H&o{X zT*Urg)QTk1eaCr71She@mk7uHY7Kq*5NQ};o$*N(S5?iNeStzd>dkpj1k7xk zX9M#5J18n)iFAEZo>K4RPm=wI~h3 zvz}=qGo$^hjtb(>D6l>D2)+d=H8$~SEJcjbhM!0)lMrAfW;@YP#RR=;>ynlzQiC4& zYho0bLIufXnhAy(uHCw447)wacOs9lSTr=*`Jh|za%LklF#^cI-{Hy7-Fa4AFLC1t z!}$5tY6|KBMXdpZO44IVZ7_)v!}Q+vyNN8v)MQlMbzoZn*FUC3-SWp^)@q3YhIlu5i|5yLXI@snIUbMlk9 zoWntPUU-5Vd zV^&lsLw50phl;5fB%-CzM53-%aod|_!L9nF(b#8dy?{>eCHvNT7ULi1NLvXglHv5tvh{5oZc@uj;5 zZz<)Z;*@Q6whb7~W606Ok08|gREV=X0{d_y$!M$W#q55#m>U^Mwgz5|J6(PtZsJEw zY^M}ouyi^*LA6(({UoK5J7r$IYGgJOkPf<5{dG&QDeN0IjmS`!XO6GOu>0OGEoJW! zRYHDsb!*v`J9Y&&bk*GY)9OG{Jn22Tlawy!Tlb020c%3oeH9vea!7Px`!bx;(TGs} zc&;qsIA!}BlFj*vAx+|PFK$8Mdb;)R>ddbUl{uhzQ#*4Lq=wGT@7c*OH%!-U>z-BC zQTRzTmw#lPh*6b*_Z)S7P-e(R=DQqSQP773UtvB{%zZ~ABJ}7gDmCt=BQxOBFiYD< zug;)sz<`-$jmbP3FycE}MO_@%Sp096)(qeMd^&YY?dmH-#J1xi@br0CStx#egBXfwoe8Rfj^4$HA2+ix zBsi&2w?fx5hAfQcG<}?Mp#G6*VC#P&VAh97jZ;oSYp3-K*$pkMvBGaWjTl<*9uIx zzKTCZZb97*QIp{eqVt6;PY3N|6-l0&7-P4fQF<|oUg2EDM4~i%$DH3gncXU`Jrf6N z=jKE+WCx>w1&b|6D8NmM!{}0pLco{gQT^6EPWG4QyeDm{;N5}Ad) z#!#oT>ikn4{)OXbXFN(Uvov-Jag6c_ts0G;Y+^Cd=#^_*q4BVz<#~qIoSU*_B)j`Z zO|BVk?9X;%^+Bo}ByW%O274stg7!A{cWl-?{P#mqeuLj{KtwHE3^0Z!qv`#UF9Om& z#Lsi2P12XoN>PKQv%Ts*5;`HseUpLsxjoS;xNadi3>(l5=oG5g_j(;#`e}) zuIEGN393QF^n(2J&?a-MRpWjObPkdb&f(OzG{>m850eiBLL3~zd<@HRLV6kq1dnsjnIck>)VWd#mml-$b7WDaKr=y2 zh#>E3d=JCVur6Lrc!!Ig(bkrey`Us;tw!L1!8L(`UroP<^DQU3`sB@GgugwfH`Q}z z(`IKtPKvCP3*MHCo%bSlIU4hr4)j<=5UjBSJ|wUI^XhEJZ%CarbjEe3mP%x*=>2e2 zsMD<2UsBMJi%kV9SIEKW`t!-IS(5o+Xn5S|KPPc) zTQC0)omef3pt72iBv|zev0#naTbk|Fzg{B^qrqJz@73FgWs9ViqTHn?iR$+Z6xBE+$HtM1Edc74_6|U z(-|Hn?s!k^5T;GO>qCkpS(sD_=4~E6BGRW2mO(AFqlmr!g~}flCRi+C=bZo*UuzSKZ(sV1b-%h z+&Vsr?BN`?U9|(MiSPYxNk!9B`yJe-?V_FyfEC%{tj2S_-WaoDmgoYz{zh`K7Q+4F zufbev$`cjz631rGDR7xojr@>M=EVxR%|} zE`1-N&oV;T4I$n0kiR1Xn4{tLFU6yY-=^WD>~ zC-2)WAbS-oua;p8R9=Dh+P`b4AoCh&Csp;QWctMl(8klfQ2eN=f( zjglz8y_hCM)U?1fQoM(NBaim6;i}x?3XnYN()k;kBf;-BfasS9T%k~G;-Ys zI|e4eArrb=D9I^(CG>eJKRIjEHdCN@4))Oax05{eLo+L-HjYpeufTXuny%3x-68zb z4p{{*p}8X9UHI{D=mvG9U-AAX6bcsT3w6lwx=q1TOg`yE#A?4<@+e5Z92|mUZ$d0b z=*c{W>^gz^_0uUGn^oks%lV^ZUK{d}T~~rnAQO-6tl+<)Gy;~u$G~CLz!7kGe0C-H zt7o1~IGbug=32gJlQq<1#~bBMgq!4q!g?4l307KYV8_MHV{(UtDRyzX>cPZ?n5h1H zK!le&d2>Nu_AfLRl~SCp1E6?&jp@xa?!ce>=11VjsJ;;%l$vb=f8+I&~u zVo>S*c=ofR{kEa)a)AWw-?nO^>W{VT<9EL3vOKv%46;fuXKUNw&@~!|+wzg3D?JR5 z{@4d*6$Frn&}Si7{RM^xKK!Jxr#CUpc7mXn6V5suz$_0ZGf2VH^_t^rQ5MAFnQ`aa znAA+6F&ZAU?$^pJHpr;~zhtR5Cdos^FF93fU^1wZ)O-^9bYWEIZYzvLXkg$C$9eF5 z(qX^)S9e#DE?oDi{v*dXkLO8PRY~31%bsW6Kl8n3jDhn`@%rSGz;9R!8Sw7-&9D;fkg+ zeiso4dr_u2^T$75fi*IBU#G8i7?bT1;>LHY1%BgUPm=C=Mq%9_SG(tarA*Kb@L<_)M$p_IG%QA)U$28Z z>0u?&0;i_6204$%JyIb+QkT-T{K|BS5CyK2x2WNne+o_YOQsZssg1fVo^t&t4jw_W>uFB9}Ak zz(-&hutz+mE#DJn+~4k1D0v@u1#7!-z@C7$0O&nm0G5CkPex$NyT;@GriYq8AA2mZ zgwL^UYD0Obb-b;7iTK+5hx>l(CA|uJ%aUIo$6l@4N?3=PnkzscH%4cSSvr-!R#(Wg zA(EFn7+|~(kpL=7pm)Y{WNa}9@X@xB$x*nOqIC`wZNKF1pvC+Bl;PE)SCH{(kxD)S zBX>7I`&o~`_&@NB3m&!ZB_w^NdYb)nY3Y7FPpK1Pg^GF=Zqx4n(~Tl zO*SQi0l&sn8c%&y&hpZXcKP7}?#*Ww7e=My^KI@^_oW=JyF8>og;^PmI!&kBCX2a$ z8DwJ2bKJJKu@Tq^evI@j=^{5xq!n{5{l*Ci+{V&druGTUg&p)7FY z4P4YU5g%Z~Rn0v|X#2y6;jWJLS`{-kSJNW8?^WYjfM*E(XKmSFRFkm=(T43YQ#~G% zQPpiNa%1O$J&gCej%}1juG}XNpMZ_diN=@3{ewNrwvo46@NeKu^KW)PWyOb#bs{o@ z|JI>C5Q%;!_Os5&9s7*pY_;t>lF^B28HD@TIe58Yh{q~ObeNX{PxG*E5d)s<@~*%d zJ|yzWMpt90IGwGrlyhKg9=i=*L}|>)tY0bR>)#)@%Jw<#VY_o%nP|){fnB{vowUTa z7k9prbC0ZIsOLqb6bU`Nb_6tz|pOG#*f4#pw?H3_e7j%RkVFDY< z6>HKbz5o>PilB~W1?8-A2AieortZObsW-bH`Ah7B0tL+}g?Y=WzAqA*qNLHL@b6Xim@}Tx zeTtD*M)&raR>gl9N=*D$lpkbkug(yLhKqyl@H>VIFo=^gr$zz$MIKydKW%|6*btZ9jY5O0ru@+tv z)ISlJ9RghGRlVpV68mmj@cI{sC9m`C8%%SR1iqHj7c%Ojs}oG_JN+b7k5?jbO%wKW zBM?|V3?s4U%SOLvnK(^PiU~#!02mMyfSGtnFPK6O_I9!iuX{0Fu3n=^#Oc6_&4or) zo>nNKuC{B$GV#eZ5Hpf5WLj}uN{aGLyUPWJX3et)m|E>l)5O2}aT z9OnES?<9Tonw=19C8DBax z+4!*zz0KBtP0|Gx9nXx4rdFeYC=H=dta2KvocRm5kbGq^`JV@j_V~OtN-f}6dy2TJ z9)ij5Z5DG@Ocb6FROQ+PPm5Q^#x-hqXx@alj~{clY+9)|HUQ=)^jwFYpWl_QYs$jn z6}q-uE0vP0M6X~9wUa1A=+VjOXIUn3z=&=CRgG~Jub1$Qd|x|EjCY1lJn@q}j@HL` zF>~_zn%Wa-byEcGeVf)stijH;XC*pj>5!3MtH7e%m_-+>+^j3SeOICaJw=mjKJ4XM z33>a5N9(o|6<&!^+smBad6KY{tS_`#B0D;JvA#@TV>F)rjELk*2+{RCzuQ^>9B_&a zm+BZJ{Db{CJZw3L<@PL?v6^12XuE6%jI^NfwCl^2jD0Wk%^1RRkeaDh4Tz@h7YfI4 z5Ir4S8P~60v_;;XJL-4obxK7WPwc)Pn^2hmj5+e8B*#TLNE5BD;aH{v$9_F(cFyW=gB z1Jrg$VA!ujHY#wsS%SxC0d2@63KpXlJe=_+{y5t;$M=5+&|s2)q2I}aTUzFts+|hk>PW4S{iXr^R=eZ78eBf!B?eqDlWSh>VwtHJ-s7h@=#S zTzE|$ZKx^~X{0y;lV7XE_NW$e{=YtMoEtoEueH3lU)_QIfh|Um-!Pue9t)>Q+wE=% z{f_EAU`ziFqLx$wwumjPjVs-N$a3B^`)3g_6$3uwLkn*YOq1)JXn`Qi&i1?gE5O}2 z0_aCWp1_3pw&QMeK>RsN(zRz816;+cK_C9r{26(iWc!o+@drrWsq5ygJ>?->*ku{f z4}5Nf->>iefI0jY*%w@%QO*g1x`O@a<3cn1UG?eK)bbKNs$O5vyQlY_G2&D(W_aA4 zq!Q_RaQpybjdp&pY)1>u*t90_^wOX2%;y;xBn!@aR(xg@&NHh6jL08GMfW}ZGJY^!OeG8~y~Z5*du+w=_K1Hk(Af`A*I=fL34ax_i{%~IW7KYUzP zQwE~ydii)(R=V*7_ww4r5RWfIvbcHSa&X7I4=gS#rXoNzz5a|gdq@VHiSP|1x61pc zo}{Mf&@o*r^+@lG^E2UJc;&)Q!P6;lfqT&sm;4n?Z64RB#XCjpbL2xt3Vyn$Qh$o} zrTpzG&#nHK*5jxS%X#0X{RV}N$Ryo~NA@1ulO7P9PpbyVzwSXW2GoT0eb(CU&sCNj z@q8Y>OP3T_P@vhxU=-g1d0UfIZs{u}%yy^%LG1tMh;jEfW6sJJX~HZs3Dt{MY!!3b z8NPk^QXBOf^i_!aeq1Q`1-4X(~K7qYq?K%yvk;OO{# zD@HBUs!zgQoo3AMkv?U^A{@9`N3vk{r$v0kl-a#56Sl_vTb|u$oVNB`c4Pv4@5L|R zpr@B3pZS7pMQmRFg2`h^txc7G9VS2 zHjq;%Ymtt^Nbg}VW=;)VE3oz1j=S#*(BK)mFW`zLtiVs%t{PNy-Yj{6@J7O{Yz#op z9f~}Nf}p#)5_;8=0M~?=JJsx0Zgn7ko5IsM^Z)lDYNiNh%UB_PA5N?!oik8D*6CQ` zy3k;uINiIpvhn^AT9{Fe%(^y5AP*GfnKCyhUm<)(0mj_!Y*@Xw;{35E@k&0#?C#C( zywz;^Y$Pk!ehf^W>B~v;$%X@?lDt=bBph*-WIAXAVq?U(+y=G7N9CvreNy-f_flka z-a0K#uc+z}{#G;OwT#@axK#abm&us7&Gwab1R()LRZ%sN-%P{0wYut{zY28CPuc2{ zPNbQ;zmdUoHT0i7scSl{ZCwIn(WcIl-Y4M~DT(=q`6}HoHkrS90!Gxu6~_gbqfQ^sgiNjb0q6!SVv1xN>7lkb2YV0KQJq?FWrvFC$kWjK$m3PrW$sQ(lh;) z`a#G0`u2;YP1TJoQ$>JdUV?|;XM}ufk?-5lL9F9HQtWhq!U;1$`9;@_5NnZb9`hk- za2`JqLaXP*5{0)2C%KAO=5MThkF0M>^Ja+fMwe;_HUsz54}eoO5ZV^Wi3Lc54^2SO zF6uOcwB4OrfXE^#TvLvymnpAUT}Y;C8_3B!e(Q`uSh5Vr6O`96SrMq*?lr^nF`HMA z0Q#cTf~@!~f7e5&mu=r&=azxMv&N@$57R*n-i4)SKXlqU!wyx!PLio7Xc?KyKbA^{@W=J)V*TX13JvZLvr+i zKp>*o@DIU&6*tdum2WiS4sYhNWE#fTewbxms0yfao<=gtr=bbtV84h2&$W7Q?1Iwy zP_#6Ejwr)^A%2zL#+zSh_=UP*yEWB$k+RrayqpJ#L<%(6Njo}pUlRk_dd3023-*TK zpLAzaY|=I9bsqx$sTIn;92m1)vI)Vr!&l|I?O-A#2(>o%&rHKY+^SoS7lMo|F9nb_xhj)hh!)s4C+=F(Z~4hCd{=kOLa5* z&s1B8vK_4aJ14Gve#8Un#Ziqjg)SpaU<2>F_Q&5Pr4b4`4`zzw1HLbG6+m(Z1+ReO zfU5y)3{sW5ly@fP?0HMgv~<7n5b8I#4mYhWrwFq0&V;piSf3qv&+pdO!`_yyi?g0Z zw|rl66mLCmGvi!aiDAu$>cNa5nMct3lP5l@5et-3RJjvuOcR51oj zNWKr-GD7p7kdcPWFESDk@Wk7ocvYL>$+6laTrB6BVTEC;^wg{k$}W#q`OE{2eZ4H| zdv(ODae^*85&q}#R-n%mf?k$an=M#(qhB&g5P+`yx~Y{-uE){inI5C|Ufh*^+Ao{; zKTt1<+C;Pfm+*?tY%*;#?>c-5*+k8w+Czky@q~=+M^73Cb7KxS;5V`zC--W3M2k%V@pu~Svy>aK@%XNCoD(oEe7{xC$@aO5~H!}H70Q@ z7sDwn6WSrt`3)|zx!bWZe($wQIx<2}L8~cbR8m926kV`CC9Jg5 zIaDN=E!>7k;C3-^IXjjzOt2(kFyq233oMQdaIE_9F_*iZ0?iHC#l9;h{%nHOW!6kM zU{_4fcZR1>;QJmju?T+q<)dUIwfj5e&AB?h(k@U6TtvCXZMZQL>sqzlY#H8xm9dxc z!99B->GfV*ZrKdRj+_A#IEuT1T3bi?WhnT4LF9p>m7r}kyMb)8J&)Y;Km8B_Bpd8? z8_`q)N;k$AaDj&1!dJLz+0=T+?sXd~3?GfffsmCWUCb*7X5kc49m-Umcv%k{Rl{7H zGoH3}0+{@jB$kDPz9tB3*%p|B3<7}LP)S9zF~HeoJ_l(5S22CIjSlUMMa2*6+Xn>= zUAs-d7+0;VW7~CesdU{4ZS2ANcuaY8;I|e;Vr_}F{8i=O#%{(?U|Pj`uB(!4K!dOD zY7?7VY{0R*I^9*++q_r!1d}v}a{9C~FB@+(39}gf#~T{p4x{zp1Kq4lRBu9kHkL3**ZsU^)hBL4=E6uM8MtrYJu@ z&P*o%7SYvgJjhCgf(ZSaN7cPiYhB%N5zLtID<$q-H$vJ^B5FkRxybblQe8klsN)0V zqOFm1kCEAbehv;99By+i8<73yDCr@PDNER~k{X9wNpXBwESc0TJq622AT8l+o0`#X zP)Uvo^c_y4vu$LTE#wWm^JFB7-e)gy31$fJ3;kDx_-P^CX0A1YyT!W;i z%h$VnBq9K$v1vUCYR7*)(gBPKt{MO=#7rF>C+|a^6H^_OyZ?eoe=GHlN|BunasFAW zw+4KUhww%MueA7fm)C4`^akUXUrz&E0jy;32r%U2Z_dH(=VOD{Cv&&9y4ba=mhE{w%ZG1Ph-1ngj7#+g{nE~W~f1V)U2W$B_ z;L7_j0hy{ZfX(mD9-#1kDd7stM|K0y&U;TU0A2zkk+Gx>fk-nl=opmYdU`(YeKWVL z?*ZTn5I7k_ste}*bhiv-hcf_#hqD3rp9L z6kp0^`0*XxF6&|8940_T9?n&+@u>A*b=qP10vl-?U~~Ne@lfQjDd=bcfRQs_0chsAfnFNxWAZN$8a)#vt+U~k9o8u36@jHC+2#XwIyGA zSu&mkGj)-d^*Qto#V*IpB%eE9tZh!%>fq+!_fl9_UrM?~vEKoH%Iv3Tc;9N5FXo_$1V5 zoHL*HL_1Dgn-hc4ug5R^__R9T+ynRUHzwfw0Vmsqqkb6B*2zduM+A>Df%zbtW3b8r z-eph!gV?7^rqggUq@xWDre#5=JqnxWx8e3LJ=*PDK-s>!1uO}&CPONC0efQ`(~pkV z3pnPH^i7YmJ9%EFfAxOgKHM$U@;<-4!U%*$;`8t*cf_NOe>cuus&z8nQiVk-6)Mz# z^OQM|77v@`0>v=e>*7_ahHXZeiDaT#FJsC8|8qyLTf+E>K$@05W%tIv1XDcn zE2g~Qc;fr8GQpIu*r6?vjGi7BYSW*B^;RMKmyLgj!I?~UwAE2Kpv+#M*rlUwXNTEA5qrmVCzK5<#0JMwRXk3 zbiNkOcItP@oAf|PLMgAj2ifKm>LK}XZ?(F{?{F#uN{>iC222MC6oSU2;1y2_xfrZ2 ziw>9KTRO$YGGGzO_$rC~oIm9!n!k=9x|Fa@(zVp+`?8|wWkGA~6M2H=2{?s;6~zV6 zTY*5rO{^4oAI)vMKB#4!`_kYLuDO6<_uwWD))vB%vD-^fwvDDWZDKtTrLuM260r) zR%-F@WB2H_OCti473ll+VyCsH8`Qpdc(3_)8x<{3;0@v_XA!iq5J{MRWAM3|I_Oyl zFk3M-b{CxCgOxkgzG<=BFwm(-3q!4dj>dTX9Kz#X zEr$a3JHHUH^N}n3k8UqHmpON2Rh7(yai$K;1b3H@SXP**+FUjV=)VQFh%EoGcj%6g z2U0oHBh9oWLiTf=2%11?RnP{}MixN%Xy)}1@}j2T`!k~eYOv{q3uvD?rd$2eT+?pX zqAl{vn=>~~z9r&d&&T!GV=#`*^+RqK+0CgzP(H3lhvD+`G2T2NoX^9eh>RG7a^8~g0TTX)J0d71d)R0+VIB&%;{hCfIe z@T>7C)QqQtIy6%?Pb^kX;L@i39?0*HD z5Mv{+ER6H5jD693bJ%g%)*Xbqxcgz@U>o%!~ zE1U94{IlUv0tuomv#ksPYK z?h?O+s{gYlmE*ISzXBCpm7|@$+ksdPW=(jME&azd|MF^MV`I(Kie0mt?IG3aYn6Bc zeIB*gVTmlA`WTt`r zW9ieJhs3sG{uB+OU`d)Ux41{Oiw*70G*m({pB^kpY-mUaV~2=OkIq+-pGXNS%9fg17p2=fG0nx(B4|Cz{FtI=v|upTpCjpb zC(7&9lXisjArY#{ETWth3r^njL(9SPN8FL*gjDB|qJKFN#*x_wxa{YCLY3Hp&@>u@ zpWUYl*g|^q%dQDm8$)nOZEj7%Ht?ETLWo9!HYj zM?>O8L|svwk}yv6YwPlISv<+l%_U!PBro16j_^w8RC!2L8e?F16DsrZ-x6v354Q3+ z`Kh@qoyUFv1_RJq|B5C3LRuA$Pgei%q{y@UduRc+v-&JIA_?$Q?)Pg}z~Np8sb(tL zF|}(XERH;o{up{aUD0e}wG}QXU94r|pg7u!N;ha&VY5N>lbq zirJ3j|p=!j(*9oDzQsw$J>n#BQ_Ot_*TtCfTJd( zdI1|Sv+)1L+Yo#DBu3Do806Gb5x)a*)dVDDv=`v|hKlhFOBBM!>dkC%!&LQ}qavBX zoZ{m%C@|dQOPh#*-r&zu^Es%L`<{FGc%2lxw|%Mg#Mn91fQ#ZiC-jy66(x4D}-JxS^Ru>~zsj>I9_SPfWDP7D z!cMSfR7DMoxiif@e2gGX>&T+3m=YlR^4p~~!18p>+0A=w6NNvN`So22;<%SwrS|m{ ze+p)8IX5j>lfh$vDWedY$MT*Nz;jO)!$42%$+IR0)K_#w1K=u>cj^s} zv`?>9_Pp^1IC_^GczMd>ayJmVP5(h{g7@i0fSeKR zo{FHRU}(1acuqEnf$P602&*QpBdQE$!{h4tzk)yzkLrdeC zZtn;G%tz$mrxqHratn>p6O^>&Jxnc6siOix6s=f-Z1~my)e%5L(LX;bZUT__c5|mg zSuto|<@#|CXgSiT`O{_fQd(-ybZgt7yJCGMTiw;FPWIOiMx}t(IjAq@{UElMDr*@d z(dySA+l*Z+_@B{c%){s#&df@S7f)eOv-|Lob8nVx8-CiD?zT{!*CfFYvoO-O-+K_P zW~W_1l+>`97kjlqZJ(Q-9wx@w+OBag9rn*|gH+=$7fm_< zv}WHQ0Tm_pJf>Qp2O|J_+V2Dn{k}g}EvrE!&(1s@;}JiWi#(Oi=dkWFS>Uffw;LGG ztwalXfE0;w_1Xo+We3Z+uOdRX@F#kZX(|aM-AY-ih398#R(#E&RZ;-oBV#b_H)1fFRRM&Yi9`3DheaA=y3kAo8uU&U$BNo(#t7fm@v&K-{Nf3<#hDRj^?3dcB$vDR3r)fX%a|f4-+}- zorp0ssPP||zrmPN)9$SuNDW?9bLYbjlb*B`fB)|Y>ekHDVviS;GVX0t%hkFgK|*5; zeEKWt3_0WLwjq{wl>>Q6y6XS^KT#3soB(`MAQ^7j0YGN~=~-%Osjig!{8!jM*{Mov zF0$B`BgVj*h0{otwA9iss{rm--ZED|571S!%eN{uKAtoOxvHQ3=i$X4#ANyA{KGkFadd8M+X-1<2o-%ALRtRl|?*Y)3WO)eqM`!Z<=f?S>Z%HabJl6+T9ih_^a++o70= zxaOwppoSOD%{-h9;+#5jV`x6UI}q`8D+dnS!6B`$UN>~*N(J&6LHH-DBEs@`>K%n> zNcCO5q;LD@E!$X3RtkwY!TlpcM#s+v&~ZM1stYO6vfOdx(EW{W>7_6PG=}-~^xvO? zxI|8W<+NTrXZBNXSPqg&hh4RboH*XErrU^Z|57CWyOY`>ZyXWMSGw5P-bNjzNf)Az zgUY9lhnxB(bHqzB-*U>9VJ$?>@0};pXJV@7ADD^N1A8|3)VHZf@+j*7o6or#P>=My zBFbCk4d?WsMG~{5^1+4|qi`f%69=acHCDe?l~ZV+W2?-!k9S9B^I6zHrv9`33D0fz zd(tes7%!kKte#lJm-Fv8%V#EO)j@Nw@9o!nz+UkhJwf{^+4ce4WMJ}+ z8rSVD-Tv6@QZhpd7P5D<{;QRd71r;Df>Hu{sGL$DygnihT*M?kG>HmI`%q|&6i}k# zBhJu7e2=l=l_?-Y?@nixrL;jKi}klRhA`b{pKR>cqAFmtqj1c8fZ3p%jPUT=<^Vl5PTIN7c`2?n^i)fS) zI);Yak-uQz5B*OJ1zlx;<|{6S=c- zI>%DBf*r{@Jmqd)N#)}>+~YezfOc6Jsf)>&H`9NI#~&1+JTWe0nNMC4qB_%KjEP~D z1+{jJO|yy!P1b{>6-XhzC4cie9fO1>Xjv>_SRQNOs<~UO_5|3?@rD5*OOQhfwY{hX z88nwbv1;Y?1;R+)k$VKt=nWp;SD`?-2yawopLcQgcFn?J<6RiKltHw!NRZf^`8Ki8 zwpKdUFba1rBCSYKK$Eh7RfP+ufTLaon-eD+{|{~)_(XmuOSCjo-0~yTT&p&YKB5)V z=?aav-x|B$*hM47tP1|@*|CMxUUNsY7neijOen<9xnGXc6_dcy{DN0Mq?z^&y)t=C zPX?f$Xusv**>jJ-f>3&xrO~R`Es1UYby?1pCE6VLaxpq64u8 zw_AU&Ji^R=zkA_8BSprok!myL)qtl?lgN~fKxC?Kl!95U%a=9o4>`)s)2VIm05rg- zCj}}HdDFEs*Rg{Pf&#lDyo1+$UxSc1TZD<^cuMVc7S?F}R4mKJu>yiB@m)*Z|K*aa z5g7R!{|(51z5<`rBm{A_i*S|Y6{XUsbs=UM<;dmuu9K%R1!`a#YozidZtQM2Jba}; zGSE&np4y6tpSS-{hB|YR?*NXJ$UxYJfvs*Eq5GgE(`6tuf z`0}BKzrOD=iQ@_~pXl^lk+j&;=3-SDS*qX@9knYMj831{BNG`Mx3!Da2J4 z-&GAUFL;suEwq>EV{?6XEfRrb!r$n3=Y{Y5%ntsPiEe9BX>;-MraX! zmyjCAMsScX5kR<5AHeS|6x@?z2YE{uyD@a9aEWFmvr*KGVmRq=t}r)Vf~Z6xh=N*i ztg)lfYKSGGSyV={)Q2Q5-|i1|^AnYIVVFsop4kFf+pK6uhK7Y1?5Mw#N(Lr&yKfdO zExor0t{?*Zhn+hs)1BwXjDxX4vhZFYEs-iMCQoAQ5cDr{ zv{jx_pP<%6UW-p6w@6LgtEJGKoG8PuLNkLKT|>v|?JQX`Y??CNVLLM`l8$mVn_(B) z!<)iT5Ube)<5uO#z-T`%wx(Gc~~#^`ple2|qESK8qTQ2z%M z*9M)9&-7n8RqkgevtmMUkw6Fye_NG_kiXj``&9aTLCZ;5OZ_WFSHb+?~oqs$f{I_i&{xaZ{B0D#Bq_|N)U#g z*zA@>0ws?mQjpx}fa}Ua%;C`;|8%_N>%w_VUW)*#i-1&x(WK1CDZFsvKn(;;^)*oP z&nY`FZpD&Wb+&4xr|rYV_|hK2KF%LsJim=bMS*{K332mv`}&9DO1yMQTEo4mg9 z8ZNtXE1a(C4NhT=N{{YDU%D<{)&R174=YknT{HrAd9r1CGC~kS8?oPl_fZLMhfYo% z!^JUgsT?zr-cSPD@MoOn>oPi~+5xn&>Y8VhU2&nbrk931e8l6sKpQYv#XGrW7uMwa zbngX7DIyb^$*&n()=4&aGqGR2xV8^P_`~W-^V(i`cC_%G5{eKy6GM=2?cr=%XKL4i zdg}!cj%W3^+8}^V30RwKRLw61BVG4?{`Qzyi~~Eb1zR3S=p z39QZW1{Jd@m*`+YLlzi=afT8$o&D^7KFCHZiPn_AGzzd!dF)q$euY%Df43-?+3?t* z%&?yIempN|Cg68@PQ~=7VD?Jb*58`@6F6L;?1FA*W zV`jbecW>8RZR);z6C&UGZ@s^0aQu~h)VuS3z;TlMek$hG{le*^03gL? zKir`2MPNVp=H&w=@dPRsEe_EeHy=f1s@Q%~qGMTuFcS>s3+)o)h*y>z*rXGhvZa0Kta{}>w=s9MfnL40h`}I+tBZsUPgfziG4~y zC$rukasrM(x&fH}C~0r6V!WaC=xh%)SRwm(BDp zr#rxo8jyl{J%|vT&`RRS0b^kKp|yO=NiR*au;_R^R*^{yey4Za-wX{6Sf_-1z)Z{y z^E^0|%9ahz(k*cNXMMw;ke`MH8{kxz$|`;O?T#sv#Jll1$9G(CCad~*@2g23e@Mia zGCM=Ec%(j6K4;ptM)uAImrpbgO*C5}Yu4&rXlsf*7rwi(3nX8Lnm9{r;sZ;6;x}1 z3G|G<#2*A&JIhwR$kIf5pL{E6>CF$775@z=-94e8t2VIezEuL)qwKBT9p}CD@@fUR z9&b0GZ@0_J)o|$R4y=;-!yq!BQbxjKaSE?q#<%Yq1q$X(q{@i=r}VHe#hfDPv?o@w z;ao2!8+3?>)3aanwX-v81;$40%eSzd(^UgS zosFeDxST-M1XJy<{^E|n1LU7=St!-eK-vEa#If^e}SdQ^%$cOi$X9s`;{3C;dnTnIOu9w9iREM9kG#S zM%Z=0s_SMSMt4d2=J~WmB*K6E0%lEuyMnx$`#C$<_Z%jPgncEl)ZL^@5#V<>cACe% zJAXt|Sj9_EX5FYJI_V0nInRfBoBi213M(^$D4H-~mFrmyxKx{+F0{B>jka#1Rh=AK zByDyt63S&m%xeRCYZbw?hJie~zJ|R@p>nKk^KOqRWChL0-sc=El_^9W3E%qe1 zwd?inF>lec5}>v&44(BxxgBqJX>?LuGRutlpjaI&&D!@Z_UhLLIcMNGnKDFh%2iK=Ubxk^)=8T)lwjPu}!eGiebXYP@YYE3s-g z7H=UZ0;u)mgeEofq@)mARjqZX6j1WUldNRY`N2oOBhT$_~Bb^|Y{5aAvKh zwO<~UEBXkmMqnrP{8nRZIR|i{@wy|Q0(%j;JOc^d+IQvApO_XXt|oKiRwFNQKc30= zIM(Ns^WZX8`jjN*d{}+Ix(34)&)Qunv z2L@t-brIh9xX-A85O%WT&y6s84Vy4!Rode`wrh6LtgUATB|C?0{?wb$v1hxlx3+1< zzG0SnsqU9Gy)ER>HHG>b1Z9t&G`Cx3{QBSB+)^*=w<#Zv`n+LdFkAysI5$}CMfBvT zrAl5pNJcSti6R<|7LSy#)$&8zU0DTDBrF|SBR}Eaq>MthrWZ{Ts{HKKm{Rt5W9yFg zeA|81HYEsr$QSP42s$!qM8_+!6up9w&Kw{byb;ANv`1>U%xK9^Kv(dGbBYk>xMwReA^AZDk;s{1%wT6fE9VG+NPh#Z1aF}V2Ya#1~csc`L z%uFIii;9kj%dt(89~01a@m@Su`+L2&4EJ87P^QmiJWj9v3^d%{d}VabsBu^JQ=pTu zNfTht@GJ=Jw?QO=+iV|lc98IGK<0IHa`^q3^@okL>sa=RZQvMFH@N1qUA~RkgbzzmWt<(D>;gsDSDCo6<&t@c|wcdc^Nn;q7=d)e)fGYQknh zrt&L9Pm$G`>n8?>t(onROE)&}E8(>SeM2(+&XwczDi=ClxA7kKUPaT8e02|6_gp-D zKV~WO1M+w$?P4hGhP!UOet>+=F{Z*7*0^Tl9@(>|io)YX`{Mj_gJZ)v#vnZFLjl`xc9QCjU|`7oZ@=$`ZA*>7z*sexbk4FK9WfUh zFE-y_`*?M)ui+nVlofi}I@soFNhvJJ(z+wRW2BU24*X0ISsO?Njc*kQg9t7`+!JX6 zGGPux%|(p`BO#BC2AK)p@I5lQy?5X4dPy$a{UISN5#L?%aeltj<6^yC^`f)O^A~r( z>#qXe-`FHUAcN;Pa5$16NhC#LVCx%69r(*>Nelw;cgT-0Nop80Vc0mlLP23z62(9a zNmvq5i`ajCz7RZl5QvmR(mx0!TnJ4Q3la{SBZm5~zKB%Hzs|esm#quFl8f$BzH;=-1C)TI*81D}yjv4Ve7_|9qLbOhAh` zCF?OAf>!CjJs>5G)&fn1$2b|z#kD9V41D0QaY=`r)32bpEcq5 zlU50mwqPknAk>i}PddlFohs6V7PppoxE(xPU#(b7d%LQLAPmW{uK^K#39znYWZM=L z0han}c0wuxy{(LBz_&mN$#^ZxxYp^#ROJD@84(BL$Ulxe5Yz*;HX7WXGGKp39%i|x zuoX}d7U-x=4tzl}uv)QMS}mSZ$crYx%Z5PMM#DkXuA5W*ien4QRJ_1{C#*t{&18J7 zmfvuYt~w;@eMh(c;iPsZxJxwA1maS+?Q4a4J5$d{(snCTUXLoR#w`e5U`+!GEWI2GwbTT zy}hkBy-!X~baa}Y6XZCZon4$=U0t1=5D^iTl$4(DK6E3LMbID`28I%0kt8!AqBV!X z_50D#(x>yfBL3N5uO(GXX=xl95?9ffB+8K>RHP!Cqyo;q6f(AvgowNTnBeONe(jf} z4;nWy^BY}jk$4>QE;%ygP87rMcz(>R@HV-p0w*+HiP-3Qp;A+h|FHo#3w*uHn~K^t zF*zAJfAHDse*M>Wy#ajxl3v5y+#IBI@`W-n&~>8S6<2yPTbR^Y3jUVb?vE7YqA!%`Ph82~H6dqdw`G@_?Qy3exHNW0o!31hAF)m| zIi#({hLz-Vi;F4IvQDL!1)h#~adzoI#^1-r=;-Jm2GIlvaPXCtm8s>Wq(&og*=mC@ zkCxqU>L*QW(`jhintWb7)pR_qDVD_@9dEv(Q#BV`*NaCU|H$UD+uEAQpr|pF2#M;P z$Y{pcq%IC->gKbty;JGz{5^imR=B>TzuwlnKiPMPZJ&p-v1#|~M7gY_#MaeXF}^r+ zhVk}Jvs^0$>v`Y(r;CrzA896rdX4I8ufTS_%N&86LC838e$vbWwBE*8SjG{`iOMxf zwA_Lo5QOK7WiB>RavA19V-Snk)UE>WKlK!vSEXy@x5M} z4}JH7cNCyTwW{;g_U^7OBElW|2vNo7L$en#i;D} z?>21A$VAV5AqmS!icujUA=>ROFv$XZY~^Jq8&|tjr*;$Rt{}LbkFm}#kF8KRt>K*@ z{0=i~LLUDaaT?XU&mm^SnCxPd?6<<8bF}rUs;VaMy0-9eczVg1PS1PmPE4)26G}-3 ze?^>0&ntW9&_Sr>!mkB_t`V9~Ptf5^oGT=}1P1Iy-zwcwIcxh#hT=|9%GzS9%Ho@r zeyNz*p7_sZ7H?$#X-l2zERF`Rjk(JewtN9r zMyCeN3U54b^#(@a3mCBolF$w?T$VP%%hcGx5865 zZwdvKV!pQFZA>)LQ0;}LO6(bGVr`(?en8mv;WW5UJAYMjt|}|A`k1=jSbC-Y=HOzz z9;M&~Y1$tGX0*Iea2HsZ0f^)J_ChY~YaL6H(cm1eD`VFg7wZ;3&yOz7kJ^)d3^Y}K zqq;C-sOi_A+(u0@A%ALgL}K62TC^7MERS#ou0VoK0y5mb=$foQ67%aB)|g_Ip&N81 zYObc6Q8!5XVgwXG%*26!@;DhSv0ZNqW|7ae^|iRV6sd5(iE0i*&dSOnK(?57t==6( zc~>GRxDlz8iUS(PB^j`sH4Er^(Llo`PB)~XV|e;iZ32mj%lYfqFYPSt$fE0Ohg-jC zT@8&-SkX`{B0$fc7Vz5F@7I_|NQ-q?u?hN2du@qvwdjQV^eN)~$@K1V*(r&Mx4(Wh zHa9zxkxMD&+V^lXplTriQ^_fHtIM6xp{s!O?vLdaRW}cLu<&}k83?vI;5sUWAMi2P zVKa|y6!{+9qeuQ?VeR&?`vI@H+n;c};WxCecj%TnZ^1ACJmN6T*lT^TsZ)W zA?N-VTmN&fz$pOX5z+h$oD%JYT9XQDkBhTw1OgVbO<^^?oxKVoLbStQC=C`hZNRxNNd~ky z@=)(`^$y!&ux5+;Z=V1Z_o&lr2L&80Uf4u7r{2~1TSQZze*|EpobRrtd_$rpe!AbNuC$B%}C&kUNn6E1Q0Fv!Tg3|zyl zbZ`Km3mf6$mLgb;e()#z3)dch^3u{K0sq@0QMg#DVqtl2xO;T#ma+#EofH|sj-v6B z&I8o{hUowM2)+O2pJ065H}(bsCUgrcb9XHX@-0`me7=bx^3-2w^DZCLy2O0PWjb2X z$o2hi7&ho~Jd<+_N{c12vs~I#HfQ&55Lwu5f$`&3ekBeT!Tvw?%&KCw$kT;DNpSxS zT!VpU8 z6^@G_hI*5UjaEnO)f#)++RH~Vs7I*s4V|-71GK-c#JKtC`HlMt*PQdouGtO~RTHm~ zKl#P%&BqT1sr50?z{nvl5S*IK&{c8q1h7hbdwYF-Le|2|s$i*scOA8C(9#*!Pv$fWr)zZ4~lXd(~N0)JA5` z%4jjd4kxdnq}BXKN&sZM-sW6hdbINU*M+|OS!0qB{=~!tm2Bo(dea0ROR=2iPLF@! z2ixSHXhPg3u$2yX1c(?cpyscob6NA(-3eRC6tEx#8$+FWUJZ zKbqS)X{5ePnqhl9RVr6WBmBAUaOM>dcr8jvR+%j=?&V4W|7`Jgx-?rq zlMBu_^JcEMI2ILEyY}cz0H8-45@OJz>Sr-&j=#UH!J@|QQe4^zP-6ebzbo5Ew(-L~ zspryYGtVkf*YAFx3l&Xi?-;^sp-^fzN?@`jtpg_%@X0SJ8J=J?5OR0t)(!Oo`gT6PRHERbc>-U^htb5iiF~?!CsNM zm3Gt3_KPZ0Cl;Ss=1}C*QlfeeO=V%eo)>6z@ho9UL55v74L(;n~003vrr%N6>`5x5^@{ z%B;9JJC{(Hw5kSo;Ti~=y>+^8<{%}XU(&?)7|CZmEhie)NnZ`}NWd;z z05ieppWHHp<1$+F2yNToyJ8~h3G#?GMlTPFoC+tLGqY6s2&JnYZorSQy_9ZwemmdTgzazWpHkOXEw3_a zE}Z;ZLjya`U9Z@g{9hjh&l3TAL>wp5oBu_%+vDK{1bl?ZGQlu-Zh2{M*Dq%xWwy;J znCy9`$vDAjxA3 zLo(s2&-!ZJD=gyz4h8OsD_!M>eC4Gc9pnoqh1V;k!q!hKAG-sI6;Bn9nJnrec`m$0 zMJT1^53QTU!R~YU2ime1$Abjc_4sm|Y+=gIe{HQ}i!(3++H|n6|G$TyrK$z(_C^#I z{A8|yC6k6%Z7YS{)FfA8vaD=izOZnSf0y?w+OO1X?)vll8JKWj)moTW^+Kfmi_!F< zz@ZW8a6G%pRV(!+mtw$i_z?XZ54%iq-pQ+~hVD0aKWfXzh3TrRtE;JTd9R=se#zkU z4$sEK&?>gqoHqI|0&0*Y{WUGkbm0)x@{HY$0U0;^+?qMlm~9u3a<>AOgF0j*gE3&c*=EftH?jTjapb{^~PUH?@2P zsW>JwHiH%chRIkLhS|wB2JGx?6Dj7M7p^3qcM^z#> z{#IRmadLWcdP>j4B!PnC3}kBbel^U*3JBp8Rf4D8bzV>&hQ7<<2tfBWYzOV{BTHYS z9g?LB^re$KB&Vc5eNs#N4`eMB^~PYWEW>1NzR*X>nOf>@9}B&sp0p4}86QCo#VMc# zJr%i66bdaM(_n)K#@&`TZg0nInyvkTc47W;f*-jGo88Ff@dhm-!6|xyi!iO{4&M?I zanN7`S{Dp*R9tCywcCc|i9fF47_EInoQA zDt$@QI+Etm&@!Q+T`L#>A0iXLYfFv%N3Ub|?LWOIgdAO^SlymT zI~SI(!1zvF8?iXDxOh5fmvc4MHP@-9WBX|2tMDa#)yd0od|Bd+zb2)d&N&cJ)%>!$ zxVQ-5;pOG!%}reeg<8GdzKI;0;)_{#JV^)mzcEPbQ=t7Y(AXKVE*~@XZyY%0N|1J*Yh9~rsocy$xb`%V!)mHfHF$F+%IL$mq`$~T5{&p=t=?gUx&<9iaU!^S2O|v) z9LABUfVY?B3MX2@!NEbrjd~${j`h>&?_WPH7b+TQ0d*j+^hiT|XtJ%1XCY}j#TMu@ z1b|0?Bn2qxDPSo*OZcRG#yV)A6tkd z57ZdFcD(>z^E-#H2EoC>MkYcCC`5dK`(u}~SxNu3yjs?9n86=><`2M6Uf zRwzwymU?W8Ts{Q|7Gm-lQ) zr|qRHD?A9BkRGQ4*a2LmO4`~{ag$E2u8zOzO()R^<&o1Hv+doq2vC(YT-~Q|%NH3et)k1E+aqswW2Jt)p^Q=3UNfX=Q*TQkl;`cWb7}Q#+ z(;`)Z)DnEDJ(4p3R|*RmggA*tFPqKvm5?x>v0OHi60S)Q8J&GFm>MKwi?B(bvyA3M zR29)3B?KTq)d*|`k|T6bWj?A_G_{BnyC19>yr}9j$&mW!+8Q4k{Vmy=$jZg(=~{MI zAV_@o)}NOH+nyx7PeH8$Dm`B{v2Y5qrJK=v^wb5RP!ymIzR39Wyk#%LHlRiOZYy^| zfPdt}ru&*%54#1oqYu9&UXMS*N3_vs>8i&vZ`!LLYPrzSueS3QOgd9Qpc#xzwHDhT z5WBL{FxQ{~WYpxd0YL>E zTW@==E=(~)p9ni6tB;0w8uP!G@~7Y3rd2dA*3eNd{RTxVf>TGfAF+W_eA^;J|kzlg0g9) znoT6)Iy-Y>YUKMgLbY6m`INoTD77H&HNZ|^nR)lxV~6@ zH7N}}70p7NCK^UjAi!v{AI6pnk(rQt$zK8KjZihS#*~C2@!i)yBecCm-QDkbN8zK? z+OR0Kpy1&d>q(mixfUsIF)+33t7gHaP}XbW2R_|ejU}`HWP=*F0Zd6}#h1V=I>BWO zivg=v5t|iDmn*^ridnAfpRD8AE;RRA5)gk&wxlf-oB*k!_c8f_vh;6Kg}cl`0Lx!l zBFfxvT*jUy1RJ&%GdmR-9w5`jd!i7QQ+-XEWOG#YXAnb}rg^-kT^*4ERL_Yo8dh;!s| z-$zF&;H^Hd4hdhcG+Hj1`!ja}>Zq@eO>fO-dlLB5mDUV!*o@!{F$8SgZlA32z|61L zkB^TodAX_$R{Svqf|tNd0%uW3w#1-?MyjjRl*u`I2ZAd{yNwk&j~EXT57VMQRG)J+ z)AfL2n0_@qoS+)4eV+PlW#hjEdK3yO-n$p<-3AiC9hS5ycfksLKq6^@qWVi9aZ4SJ z`bR=HrAJLqpZW0&F8c`(NOp9dWpYRAV>?CaIcwS6jy7tr;a$JlkSRn8 zmdS@uK%t~0!HYlP=R&|B*ST!xpGHW90}0Ib!JxPUMrrF09l)dqkVUQ%zOE9Rm6?=H z)}Q&^B`p03ksXm^O4-SltFtU~uMXO5o|ZMWD+)Sl_5s3V6yy?gb}LYJzF}2PZNY!5 z!#xNn`8Juhuyi}r_EixBOLWr*6M>t(pkO!{8N-N53_ZO12PB|2yI8lw?JI52lfS<| zAaZzen4-dyDQr#W^^0WWgLPUz8qwwVQg0V6YmlmFwMZwFzJ0lt>M>rW9=!|M6RfD zj$2D&|2sJ%9~u-MJW0kW6&Ai%(Sa-+$$)78--+|L^wH-HIB#_(fP`g?{!We$aJ?CK z-YDdM_*4Gk%Kw@E5n&tspHT8t-p2kB^#*EoiGOpUz;3IzKlmXu{_CDJZtuyB`69*@ zco@cXZfm7iF26S?QHcKW`$fQ_LlBXJ`z-NM;xBtq$YDy%lp2fg*n(=k_TGzspoks9 zr2Y9P@c*v0=s%qj{>{W>;P&SRyE{|hwRJEencn-VniTAR7d?D{4$$2%yA{i?R~OxG zr^Bx&lYe@8Nm`8W-XHS)+OxGwCMNEI@)dy-K zXBGZ>o}@pT=-Yn7NDOMBztZWU|J<&~O7PD)cLRWB!xNA+v%KMB!J&RH%e~iawt1N; zNP|J~xdJA;S^z#f4&Oy$R(Ier-T`ES%lXOyCzW^FK?cn9Tp}Q(hF}p;Ig>rs*fhF^Lm62;d$OpQ?DB5ss~a< zfOa{e2P79{O;HC?qzXXf*bMpzIDcxxmwwW1*3?Hn1M^1&HWSo76|rr1qXm$om_F+a zY5K<|?CfN`yu1M52C#l~c6J^gufG1nN5ljESw3DOAnuDxvc&A&dP!B)IiQsYJQ{sb zHN}pa#)v7&&)@lrKGT*#QmgiQ`Q@JdG3V~iWp#9%?;xCC(dBGSTghnxS0ccE4CFjU zqeS)73+uj5@%Uf@O;d7ze_y-PgJZJK#q)PGkX3W<;y9eIE#6XD<%Qk?J*zh0@x{={ z^91?%`91GWjrTy`0jxeu4Op2zNw0oiPV)85s(HMU$O$T!VQ&crGFPtMd)5tjqq<&? zSnoP4O0eZfbHwkyKEM|BS4a&CNoJ{2eeLxKA|$rS={-95s#Sqh>Q+)(fW z$WEn2MS}*pMUz6ak-_hfL zX~Tb*BuImVZ!nhDFf~1_(r)h;KSz@Iz`=oP2{6XHu#s1oVXVOU0n{3QgRkF0`G0%f zpJzOC>F7QVHCis>a8giE06FDg-?;ws=~A4~$}M2c2nZOv`+k0V%=V_eIqXp{vssR+ zCRf8YPcJQn^)UdBj>(ZbAg4cDuaA*#XlknETLBzuHI1o3?30+~Wo0;}vi>;b*o<0Q z9cFM(vi^X91$-$l7>I+w@biA$x3@{Ag-p!MTx+!4sFj-hy@>WLflAgv@(GCUMkefL zH&E0vDK)F}=Ajxt7rP~jjq{?s%OIhS;H0gpG`$K^D}&?TK=Av?RF(wqIQ9ZnVz#@x zJD;XAw$n`G(#r}aKnK~}oZ`toi^BilWM> zt2AG=n%2zK?_#+HRKhdk{4d0_gr2UbUT_1?y}De{k@H=0XkQ{ZEI0Iz5(2n*YlN+A zY`Q$}ZA=bo>*`izMn30wS%?Iah>jb($zy-ZWe$uY2@Q&MWPVm_$yHtehx)kQ&|1+@ z(bkrJqJ{cWTGl2}54&0IPUHW-Ep&u_t$T_l6i&Zm9g8M_b+A+9@Rz zj4*u}%s!ZMYH{A*{&8nT(kVW#?XN#EVE22XxAKrgVeUSc9$}`7iVa-vjebcax0vQ{ zy7j;GZ+QN#%2t!GJdNbC1vuYlkv&2R%yL{>bkpmi6%YyQyyFNA^93c5-(^|n3RVb* zxs}Lz52`~$#H1dMd=ZBZxatRqHBe7IY^x`%0lvE$J_AWLUOp%2sZAY?h3RQf#GskJ z8gJm`_oVU$nZSk)%R`%Z7!93Hi_e~(p62E>!R9%I0nXW~7RLQCSQXxMD5q=* z);YWeHrRXEYpsqj3bobgEa~Aml+LHQ$}S`=J}&?x_r9)Q`KTZ0B+1UZ4S`z^N$1D0 zOqFA`(-y|44~}rQ6_v{oc-Pu+hQdSMOr6@)U?NVv)q7yTUjS`v?~>TtGTvr2D-IT5LA* zD>#eQIIO>L>`yX8`u17^fJc&L$?U_#Eh<&IG@&LlqzX(v*>h!jm3yNs%>$$jSLR=LNZgEg#s2vq6lW z4Q9|jK#s0t2`A(5-PMH|kYfSUS_Bp&G76@6B2u1Su|BZYoP1tz-X?Q!22!ra^ue?S zWkTMpItEnR?w5i3S1kEb9)DdQby!vYZ31u173LMURxk+cy}f6Rcnq@ENDUd|=R z9q#Y5_8k_5g+M!D8b`3ZS{^-Lvf=lvsZz#+SAX=hQ z^6>I<6U>#Z@T6dd(?A%eA6goQJc7!@Siwf~~ zT^48oQUj;BbUkh$7-I40^*ftYx^8MuWe%{WBJS_i4LKy(-B+#mAb-gc2j{&>yY#2kTCTHbcycu0mWj(r`%M8%AwN=6e~F3?Bg)CG=q!F~Y)ySQhHZbzt%F z`YDd!22ZWzfu#^7U!QOIO)#Vqg1}jP0#L#o&Q>@y8wGPgh&T&8oY**4cL*PbP%Mer zSdp!l8+6AH zh~uj5_J1M56JSApN$?7f`pCx$p5`Gk{FV;lI_Qtpc!$YZ0QUL!cmQwXbkwYlj^vS= z-L;M@hQ_A|mHz2XKQf4n1E&ak!`VYPH1Yxe_q$Sqr`SycM zCo^CF#Q44jvjKTapTZ>0KS6!^%P=>9((6s9UIwRLf8dh6RUU-tyZ!#QiKef9AaJ+q z>s;cq^}JW>+t0vtIGR@Qa{pHm1qIh>X3s-(1t6*|@F>XA3W46Ycm(cS|#>zSY})rH5JvxZRIwHa}UI*PKH2V=w=sR4~+xdTv~LRO~P=3h7wJrm65$ycBv9914>z zY9Z9IuiW}^NFSf*>k3krzwtGN4RV*%e_1v-$GmEiA(0pes}4h8Yt5ub-9)Pu4`)uI z+xf7wq=d`{0fr%Eg>9$ym6Q{e8Wi;bTlX@ z!eqwvxs`OG`#qx~J4o<;FiN_#QPUsVvx+~`Itak9E}qp{cUES_#G2R}n;O{J*;rr7 zn{Un9<{pJ(yF^v1th)ZaufRKfdAGJuV&KNzo(Zzzk7DtXrgBml258zwO`FK+9bJ8DB`u`LT zwqkvqeL$5rjP#pxrWN!#gQ12;C7yX2)P60n?RsD)_Ho|^YKk0RFFw4O_7?3>1ZG@v zPf}0^O`=`1Vc!o-q2<1NCkaj)3-eu&@CZyq54ysS!4A@<2mc`u1cFn6z>kU8#+!5ZG_S))FltXfEyHs1EHV^yHA^x;?M?`<$09i{wCsq%L)Z7;nUh?i0L6d zp&PxhG6COe&?=zt`w@ZT`HO5R+ktmLPX;4w5!z9#fxQpr-MYEtc;MuN7YIBA`GHXJ z^22dR5eJ~IB=Te3f?_Hp!eikJ-E|teXXoPMg zQVu8+Clq(i^-%brU|6a?C&7+e8MYv?n%g;zdIaf&)dte`w-IhGV9K{Gpqj2`!9heS zh^p;#GazM=N^ML<8~-SSe?VXYVvA1eH>kZ<^R6IVct17(UlY7Ud#Z4%ehP8{e}a}6 z__j4}ciuuxfRYkZ-b2`%)r+)EX^-GW(?PibeGy~2>v&o8Anx_y504Lcw_q%#8Tu(K zNf3D-r7%Tal3m=T-(|ccc^PWuSAS7Pd$QViw@9}%E^#mEc#`8dEy)>j5_6bi&@sU= z<1s~=%mOKIe8C9#HL?TSBlsigV-%!VpJbnO07I=%zIeWff#gmrxsKwOSt*A`qbj4| zJ-E@I@ob82Gz>J@G{tis-|)UAD3U2U&SIE~lWWm+k^&c~fI!qmU=TNsfuTBbcL&qwC3@RB|b<33ADs6q_`! z^6!drOF*TuMK!wH`F-PxCyJ{@6-8pwF-)B$uyiLh&@@%be5Fg(TqT7SdwGdfN<~gW z=Hh9^r=^*NT2)@+?i#`Q2GxDaG^*U?*NKeMuK6X?bjO{y>dIoua>{uYohN=L0qIQ| zr5art87m^r0X!o7I-Odb65c8zV*PStas^_$*_1j7d1C5QKHnfs=Sy&{>*oINBjLb>|<~@jYs?gLU$CqBfkp)ixD#%Dd%!r@^fC ztb5qZm~mLhad~h}@S1RKBD8_b$HY$^@St+H=6 ztlqk^@v|kXM=dir*0r`jLf3|S@=I&?VtB-(12a#)H=yM(-?Kz1tYbLQjZ+2jo)C*x18$yU-T zqAIZ}2pd+LJezr&n6>#e^fk>jQ+|5B1&_=dQP1CQ=hp;Riq{729G(*%n(oy%=f6lE zA)l0<`0nO!_McpydVMy0+Fyt&rnP!CXO2;xJD**j1Q4f@)zB{ui7A%g*_F34#q%Yc#2+NQ zB%o0Eeu9%TmmVw{D$d>;y^MG7H2-eCY#w<0`#AWxz|v~rRGn??e*1&5AZ|Hp8>G_A zlv206M2i?#vU!er!XqUe{ZG**Cr+kz0SP8k&q<>y&+}$W1xs~H&ugU{&i$xLM-A0( zB8~UWU8aG*LjsV{@%cH4Hbj>aJB~}d5}xaxRWtfOxMEXdTG8Wd?$j0)$>+!Fa@Ebd zSgv!VU~4mQ(o|9V==RaJ{*F5SkhJNwg4%7EJ6GrQDmyN_F}bac z+>V?gm9n24b#b@4t{}_AMC;o~Wipi%Zj9;d3FjH?DM9B=*Ive6=AzPBEnCs0y{q*@ zrChDO)5Yh#=jOD}@JprhC+9)u3uHT#wQu*O7iQBIrCed$Ktlm3Lp8(A=1AL&42<;f zjE@96gn0Y}PA$#LWv!24J8`pEEn-Wo-t51RqAs+${}*X*85d>qh7B(uAW{mtAR(v- z+ybuT(qJGUEgb@qx^%O2in#PjHz-KEbR)4e0uoZvCEXp*toQwY-{153`QQh+X6BkW z=bYm_&SUmkMeth|*kqR7Vj^e@XiM>8Iz8XAOPv#Aqh-_a?mJ5_PyVSti03r#*zL^f z9Of+LOrEkQK007E{`(@cKRie=CnZ;i%e{J!Y(~B@Udh~{_eZjv1l&P<=FC+|zW1E; z295PT(E)|tUX|-{)ae&ePeo62*!XL4C+uQpM_x!?@-NOJr#;=hic6Di)l;7Z$u_6F z)z|QR8%N4VOrpH=j~y?vR(s?sYOP<_)Xg4D+b(Y!&Lx~Oi{SH>c3W-eNZ zmx-k)zueIfedRH@RkG1*l^K(n-KN)e!bB`lFY$xU`#`Q0w#jxaB_dT#K385qDPAEt z!8x^l+i_ta*!)i~)a;sf^hMc0)sI~!FNd=NZvi9cS;9+f_LQ7{XM^Y-Vl*$M%BM=Y zNYwz=O;Z=??Qq03XEjx`<$@;F-o$KjNGy9TUo7u#H)anirCtyc#O>0 zZlC86h|hC8|AYqx5Qr}9zsuaeost-b&M&v~Ellp$*iH3V+a4NX3vODVYUEIIb{scd zI5svmpe!;^bvrA#zT9NrNHOtB)qZb4w4*q@A!a_mj}%S0^r)YTO6G0bsN+o?;P`v5 zd_GPdTrX#qFrfk+IKGR+z3r6oS0E5cuXYyjpPgr{;MRHs6AuaK)LDcq_|tsQ|4WtP zR->=lqn4%=0_O3$;L|-^yv+fZ;Y{&4i+3C%g*w+Phq-s-sP?CbefZF_kam33_9A4r zmV`QR%fO*J-sI&_11aR)eKaIO14=D_L_RIYS|7>r-lcogOiDHD;toWHApVLEALc7B zar`^h`Zo@L#2iv?7hSx7$nby%rQb$c&e~ew=Hi~lVf;=jn}j|rQL+#jVH9DHF>C#8 zxu`9v=pHgyIz&bk6#raI`?e|7%-e%Q@J|X4D%S$f=QEfC89EeCNzL&{3taEr~UiEC?Y6 zxYFN^J|8Cr+k7pawkRpAHiK+EVa59nM{UJbZmC5ay?7{>m1s3oM&M%sre9o})2fL# zaE|_KzOZr`9;A{54Vgw^s~7X$mVBbgrdzp%zx4uCCr0%s9H}Baz#n7t1n?j<;6-{R zv?k)g0@BE`bOb$wT>vcbFU}ufRv|kpsadbeC?PjNCw=6gGxS*GdCt+2SJBv2$VXYw z+5;44($iP$Gvh<&>LPfO7+@5YMi{fw8%>k6r&!F=N5T zE0X=VzoDg2B&leWjaSXo=5Nz)QOh zX60H>MmQ2SHsK1-$B2%24qP<)(}Z4|M1Fn{CuC)Gy_Sdj$i&`aYnI&D-}umy44R_F zby<<(VZ-{hoX{r)m<>B0iaH}SRMT@+&L4eXb*r}0{DC|(*Hu2&titt0PS zQK1^E)E=isOZB&s(4=9sbe}#gU)DcUCBx?8-TVY5C0&b?$;zbmHh<~8Be$2m0{#+X z9?A7PQ?`~4L&K21*I@90hAIgC4+7kidY-ekRaL8Dm!f?PgKE+3-fiiZadbnAkUqx0 z#V}5#zT0Mh2rU*`3)v{fywc7p7(`^sgdmP!uS+Uv4<=S*_Mm8HcD;J_#9mmhxXqTB z>-e``EbL4cCU;SBF)Y!_;=^o<51+&8ISxU*Y@(fFL;xmrM-VybbkUnkDC$TqnPNi?Q7r(tG z>Il`l`x|*6a}0huFT0u;(sv6y|80tKv@DsGa^?IT+gs78pqGAblp}l$7d#+!fv`ebe9Ck{GJFdLM52h@pCy`4}$b{Iwbm>AL}ZN#QqIv-%K$2l{H7(N~J5 zbZdEd8INbHh7H@9H?g@5kDm>S;rmp9?X-Li)s;aW3@5ghP_`b_tKMo)-?4F6!*>dY z7+QlK3wIfD^YV^`xN^H1B2Vq?5$MIAAvx6k#|d>bc`0ueRS&O07N3LFDB(=`AaHLo zV5&QZJc05sCG~^R&|$7SA;jSxSc}_G`Zj^P`kW&=zb|@{#*&VUPm1?diLdc<|fBj_X`X4mkp3@DM3GHbid?#)%bsrnq z2G(i>)eGDo6eEsM0%L|QEUHsa$q2f%jsD>PJ?;u;Vl5iNbPaj__uRcjfKordSS@)% z`OEL_chvq;tY8tv1~@Y|#C*=cfR4@jr##-y(qGN)eCbX&V10)TuBN@!{vdO~N#x)| z^(ACSniX$85&I6K;7=oBHNgyjrCmJvvTmVFq&#JX0J0+m9>-#@?0S}JZ)MnczT29d z`vb#G-W0bu2{!lAx>McYFBoBN;N18iaBVWFQVBgFY1+S$co1tfJz|{;`wL$pl%RPn z0wnq&^eVT34|VSBcAP(NV=MK(pF7r+eyCtD#0HrMhf+He;In&L;Macqb{xA}W!G6u zePA7BH8VnzYXE5{j=vHi0Tsat5bNiE(-P{H`Smx~(&SyVhG=*aNFH>s_;_HJ zEhc%l$^BHne5v!Jv7zOF%Oh>~gPToj1f9!Pm>|VUU|1+@Fh;?bX8a;Ib%K3CN-N(j z_}Ic^T<|6|Pygxu4ag`f8nV-Dlyp4Y0w+=&`V~V2w#hyCOXN<=0x;n7fE6}Ku>xo) z2&;hU@u6wk7-fgcAWIt`dYaG+mHV!6K#GAe%==@nVHA95GT&=lF;MDIijNP&ZM!F? zfrmyzmye@!dm-(tU|gQiFN;#tQ&$CD)@7R)TG@LK#zcYj9lkmzk`w@gS}Pe5C_i$? zbU!V#fA-0RNnGge+htYO@$Pg=1^*QhNHJJRlSHXHBmh$o#pq9a*HY@tLME=iF|yt9 z!fm+X6^Z`}VKEq~DOAs|KOFFUy52f5FWhk9w{Ro{R*TIWB0~ zVgqaacag*STbT>*i5bVu^YDrl7{{geVURy;Xh<=3c55VK16%ZK`955DIVn6fd?`;uegfT@x;_4T^@4~w88>n*PHVG-BfpKoY2Ss$rQTO&S<2Sp zRsojgZPps+0N*tMZH_@3-OPu1Cdvn$KuFxqNk zBrEq4PlgFhj5U-P3$=oI?URD11N4XRiiWy^vB`9}eA$XW4SF)FDxnnkm3Fe820{8#S4;w1`gHfj@ zJjsRO%Yg7CLJ>+Df19=5P~?`ia#QHXvF~YHKZH#_)KZ2ee>u7DvljtIWwI?}8F?@} z@hqr4l=S1=Y3#V)Ft_@SzTjRRMhUOl0AEHNv?LB~_y{$(S+pp;mZfUeS~cGL5*Wp} zdc&0n3SbnkIUn{&WCV^T0!;S+&dFYXi>3&D*n+vGet9Lr<8Oh3P_9n>{5lVtDivRP zn4W|H@KgqmZ{`ubk^w^w!ZK6W|G8}tmz>KEBf0P*HZ}7Yx3BZsxezS>vXTvn0@eTu zRbj9rC^7gd49;%53XqXM-6b}6ijP0H2w&zA=q3epH{lO!J+sIV?lkCErJg4u++Zv= zzV}g`DdU90XCAkXhIZbTHx6d^2#C8vNzRVWMu*ehNmkWrQUtOs@a2e;GRFgZ+0o8t zKaYCjfFNYQ0o7Rh!~^tY1r1sS>ShM zvMQLYNmdXq7wR0BtyHs5_mSREvUI2$u0&nM@z#*4^3#~D5$~wA*R{TFVQyx>N@MUh4J)0qYWt!VdumvLbBXf*V zsjC~Wo0im859z1)js!*lM8x4ZhEc77cpA*_R#(Pg?M<$vNj;aJwpHnp)q&xVf1#O# zYSD=VtXQ9;rm77R=J_Y|mc z4w;Z)rms)<2_>`1jX0c2FY31Cr+`#~VV_|HAL($`jT(U)+SI<5ReK|Eb}mGmbLfa6 z6Tp7Gv_b@?o|Ar|apM2ds7tA0#!6|Xm`KYxxCnfwd`ScyC1e5&+)GQ3hAV240kg_n zg_IN!+gk!=9DI*A zTG3U6vpX7oamdY7d3@Wpj32ePxIRpZIz2Eo+gYn?W4q$H0wB0o@1Dxs6-hgQ;aoVw zK0u$@c$Ki4nmxvxPauXQ7(^69sUcV}8x!AX-!#qEZWH$BB;-a-hn^-b5aAu~?0+gZ zoWG6>2?XvBx#B5{@{oKdM{#6G^(8)YW?+XCw`X%)#sMg=nY)dj<_fHGTbuJuQ%3D} zU|-H%|4x5kxZrz#^QuQi{UE4mznE1Pd<0dUBa*ZO&ULoL^`XvLZPn3oy1lB8hx^*k z{^AC~gE%@kJsX62WBAe}Vi8bBa7!?Ab!jh1?d^7bMVAU40!CE z@mG`?pwGIO`kyXIshi4f^}4rqkwdN@Hd0gjdDV59NZ2nE3@C-NK>`8A^Ran$_HE>@ zS%Vc(JC;dVaUxsx;C`E;+8l}G3>ZpNiPlK>2dE&7?lhJ-RT1=8coT=uxc|dvDs$H) z-~27-%BM3?E42CTu6;l8;^G+keb>eONEt#8kDUy3NDE5e0u_LPO-hlaG(;-i7x{o@ zOFv(@^fR3S#7z;5C`>B)*t|3ix%)jZbN(r%BlxLOmJF?ZG$I6VSqQWs2tCC@`C!AZ zF%^3AzJ()i%fySlg~}u8%pq=olDYX_D)X36r@i(niZn>-{SZE-qm)+R*WxBHZ(HLB zFZm}Sj=-SU$xyo)Qr(GDR!`z@-~6#z!N7bPZ``0cM=p5?{BlZ3T(5jM^f8QRkAqa) z%gs-3#CPLpIfvxQWj*lD-XzO)4;K)S3inY@)eS;Jn6&>nO@1~Nzl}JT@X$AgJWS$_{4BOY5NrM_>fX}UBy6mtX^BxxwmyXhoh~u!6f~nN7VIvaxpFUC(gnZqUmBG!r*js zOvb+MpPldNIOXq<5(#fo7~{$gzbc?zr_{%nrdO_+KBy5()S#97sB-#4dZT%7X=ejl z*TFa|4qvOzQAsWVqb$T|iIqP__KW87{F+ibUmmq+y;5^t?(ME(J*0@$&{Q3};F>p% z4U6$MfO_&&@zah>11HF4mPVHf5eA`xaBs%M8McLnZK-K#X3!{^_!5~`b-pwX5Kgg` z2MPR7+xpPRMQz4OP04(SKb1=U2^s$jJV$xZ`xI;;hOh$_9h;9vISvQ*7Fm^eH5|b6 z)N5FzjzV6$W;w>BZju?4k{GnA{MuALA}iuAzLN)8#MKRxlGybPhP_mbjybrdU;i#& z?{mot4z45%SYLhEiLSeGRwcgwrIFiRfXF~RwP%T}*<4`ANyHgN2U-Ao$uSJufeCC! zt-~!NP!7<#T^YG=ApS*fX;_4l-B!DkeX;R37U(RlOxknx8wQa)mGMYt^M8iMnjS`V5-Bk zC+N7uCmAklUR1H0cW_jMSiqKxPuymIk} z(U*~?Ic(gUTa9p-Nkw-zF5(3&-^=sf=Hy$$++^NVnpTklGrChGM*@cuSua8jASHmS za4-X;o4_`2&Lf&RPS?4MMMErhb>7KbTTAk^p7ceBfNY|FITL$PKgNHMmguoa z{FTS%(D61X11v`Ov&N$)ZQJI5n*Hndzw7`O$;09d$AXp1qPJRNS}(9-ceAL)c|#El z1V{hGUrE#66WB~R%9v9-`tEf!U9S@`u(2~2R3@RCT_|F6@lEvv9=OCH{9HfLcKnJKRz@e-5x^KxyS3A+Os8*S|+9p)r z*EkEqxgP5?l(`>tcI$cYB{o)(j4!TG51iwxxRz#A3$$ry%+Yr&P?QTXC#0)>t=(o8tTXpqKb@Sfj2HzgcV) z>0rB*K#y0$0t8tQSF*)rITVA7I(7WU5St4D7Z!*iLE;@?tY_cB5pOz+`l$LQKE6XU zG!kX0qfm#`Q`V0Or-=a$L20s;X>!*!Ajztm#n_EC0akb`L?B6a<<4PaNq!i&d?!>a3 zDy>|q1-q?YI@FW7$b_n$7xk^Xd!RpKM#0rBNJoBvQ#3{KHAgk8D8{T+?RV=t3_$%m zV+^_qJ$RCLI#?pip$(y2wECPp7~vuQrN#NJ*Hqgv?^w?^`yH9y(AK|8@v%W=C-Cr7 zLXh{-lKR?%ahGvN88Z`ev0o0=K30v|%@HB%maAi}892rT70Sn%fupQYb-;^&^N`F( zW0{Od18L|BhNT~+XkXV)tTuD}4S4z}t0Y#yko>&Zt*90cn&jR`h(mUsv-*k_(<160 zZ)2Nsayz4@4Gzuny5%P*=(ow=q>H*(34DxZ!dTq9b6@aj4~b+-J4=K#G`0nL6^08! z_4rC=rx}SlS6}=(-%Z&r4jowRTB5eg+K)AhOQ7+ojgV!w;e>MQcXjiTvr~$@v+A0f zd2N(}Xv;$SmnV;qi08s=CcsN4M*MIH@-A9(QcD!7OIs^=xwQwS-^kBXVt7=<_*xaj zM5;FFK@S|EmIX1tR{F?F(h`__L_zaPoB|=xD>l$L<>ZrU`{ZpL-q_EyAmabjaiCBV zuop@^$bdKRE02$Sqhfk-i;HN|WG1x#$5o5ooK@*x^0cr28HcYACwv0v&?2V_za1Rn zJ55HHBTuKXZX91uX^RW5qW;Mfko?72XIKCh{HmeH1!j{3tAGC&ELS!>-Ers6;xq0U2Ml8p7o1W%z#C_232Z=3$iC@U80 zLjvE;J}$t43pD`TzgzShgc?`*y&>8xXvrAud>VzLNqGj%p$pMu9kLSZW_6+7%IqIP ze5XvTk3_8FYMt92X*yhukOIv|O9dji64vUfmTDvIZ*=>=_Ub{lia7yl%yisLR?F2r zG8h*CXVY+HEK)IBfIadMH6=w|2e;tVgwcGj6|{_fQvA9P`R0cCP`UOlj`7?FjAw$J zPky8PFCzIe+=jPGRG%UKXNV$fJ!=vAU-=qi>KdBtH3_Xp&PeJD zlfp6LWL0gId*l3FlinUr`zj{EiUFdOzX77n45kx&&{tu3vNWWS3?#V}aCN-oa>uSF z^O`7ES^mmDo!d+_aTh$0=5PC3UPJ3sV#5n`6}%!@=;jHL1HyWE>tB{z z414ZA)qRZkilegP`VobAKNVSh^S_O7CA8NaV_!Mh`*xLN;Vl#w^kKF>(+p)HZHGv3 zq+)b}g-n{&hOx)9rafGbpK&|Up@$=d*7nKPPxV$s- zI;T#zOaJ`3RO-pk>ECTS^BFNN4f_7LSKy)%fgZHM`Mn$GOPYucyr00wmX=g<3D~*Y zHBBnmb2qmb9x70)Xg-P#yL_E#IUsu6P}53$YXO+hWonM{E@i|E9+J=T*v|fd^`4QL z=*9}zD>F02u(3Loa(ZEB;OR{h7kl&**=C<%^$`^KKV`rRirINpGdso->6I8C(Quzl z*ES3GR@N-*N*rxwU4`~V;xfLTvS@$#%y6i zwzkQJ6e&C{Ec;fGBr>G9DzoAJSJr!mSC?v7WXwSb=sN1PJfcgvz34XK#6}*wK0o4w zP{jWUoN+0J)}qPy%PhK7b-(OO<2YrBmP>1!60Z3`>B<~|o{}%nzoAxXLZVM8aDY~v z)*lqQ^%?4j7bL4QK!h|JF_=^EDB3FClPzm_JffL1yqs;@0tlGRZd^8n3JM%h?t=MG17u9(a8_ z)F3da5h@Q)$n*IOY=xY2rDwdKWlc&}wrDy&udN+R;!rU<=4qN}^g}?eEZkD=jM+o` zoumYa=_LdR29pR`;^pz*8QMAW;klROSRn?R0>aL%QO`XR(GNwyPhE^tl_WV&8;&}M zC1)*iGIxOf2%{oFo_}Zmp2yrSE^nF<5XM+Rvvk{K=*c+cC#zs>uq(OV-dO-Kf=eqa zv6wfWEO3H9we=VkYA122$SUS^2OM2xso6c6dhjmFDme9Jd;bS`P8F%l!}u#v-9|Yh zpI4={cow>rbQg>QepIc?|KQ!oMTOsa2>PRI9jP<=K8!1S@iB8S=+8qG6|Yc&WgU*f zvA^+jNumDX4Qznw!Z7cP_VSiBkPDDMl{3g?WON(;^tFmih7P=<(`evr#=coU65$s= zuy-%u2kB~S&H*+_3}jVDWBR;mH118BToxpB`FsX0Fqjk3o!HN^N{+IB<8fh`XKBzq zYG6apHcmCxuxUY&n+K9GUd?KGLgX@3pz-$EzPEn+tfF`1xI~vz=q^!Cx!~kMSxR@0 zUW`+GvidV}flwrf0yg;>1M;vRST$iyHgx(8u{^wie2BM!mKJZP8oH6Aum3N)8DUKj z^%Pg7k}pB4PAG!TmXJm~JZFrqWL4GR;rMk%k=2O1fcqDb>#>r21<(Ww6qOL)|9J(A zCJ7;E+6hpyeW@Mw8h!PiCM?{F|D-!nRE?Ah;Ruv+A+y6(e%I!@ z>?}Pvj;YS3{iuH54lF4?iXekzFwvlJLiZ6Tdx%`vjOg5tv3>ZtDSd`TxfUD;%9I`M z;N&dz+I;c7865*CpsJX@+@OQe+`8HNdMV_SRru&R&@&X1_w(C`k0b@0#J=%8r{h1w zt8ZtSPQ()SxBTgu1Q}k^YuA=QnATa|wx%d%_zg_V??w?Ev(SjxAn=Hk{mzCW><)SR zbI-`|Z?+dC6vpWff!i>D?REBe^!;-6w*a*PAN$cC6rR=zgA7-Xn9)8;P)aH4EV%jQ zn-6+2%320W@6hYvqd4UNsF317sQ@tX?tf)r(1s9&82xVE`iF6f@wd(0enoel`lKd! z4ABB-mSP(cWc<3U?Abt%DB{r-J+v%P)x6yGW)*)U?6P_6K|S{qxa}SB>1_QPG)>Yq zuDdY)-(MD=edmwA;_+=nL_pRJGtBbwvbua8KgoRJHpremDQ9w9q>;n-~Wip=rxLjM}hNmq(KODahOm`Gkl+~kBSX@YW zs4qJtbzW4+_VoE+fL!UKN_6qMGOdl+Al2ZIt9R8OF4gQsneT-0SM-W2;R)C4gw`cL zUYyti+Gs(nkyEbQ;Qly8pu@-`f0J<$=eUIXctBEQi#6&QFJV%2`nZ9R*}CK2F(H;B87D7^9!q&a9fJA0<&J)SylP+YrkbDAQ|#1&60 z_^nyacx|=meo*Iu%i2?Z1AHti+J_Wc)5iJ)rlFmy_=wDw&7({8H+^^Ul3DH6{L>@W z!!xd-ojZaz?~DsIB=GtgMaia(089|XDt<#)9pLLYXI_wJA!S|rBjXlR1uCQ2S3Yr@^= zL8IWNzgGYwpL9>+;g#(#a*3~1I%*tZqljI9Ka0z7tT!-!b)V_u$dd0#m*j3|QI>~R zNK>c4*)|4PXz-8vc@lO*mfr7{{T}yfCPKm(#-IFGjuYDOLBaXIRP{YmvnZ3%K#PUC zCd!ls>wHW2&ECs9RwOGHfLOjr&CzuDNBJ!e$xVN$_;|Bf>J$ffvcS_vkS+7`XrW$L z>kB8F5>VxrN)5d8bY?^aAx?M=I2faEBisTVNKBIv$b4il!WCP39fEF`il1~i`_Ww4tPtmODF%~R`}IvKT0De_=;^H&1?~cED zZZ^T}rl962#p?UCxRP5S2Wi)x z5^A3z%_F*J{)0CKZ3@kewj!z+))&746E7%O)WXTS*l(g1jM4G`Wx&(B<)Z?nujaZ3 z={Ht$9K?X)7jEN~2TMXjV)i(?zo$%6se?uND#(WuQm6JMk$Q)zQHw^+{caQNbyX}s zdC*(Wiv5wuGHZ48deDP(4@j>94KWIqRJ(qu<3Hb=3f*wY4Ka%w_*nnn!^_^TDR^WU~A6^-=w;`yO|Y^AXpOMGPlnA!-gV zoXl%40Q>cA(?=yk#Ua2uUV47u;eAJyaxZhkbn>+eRquD!o?C5{=)+D7$KV;MBScYJm>4WYrYYsAYezl4r48b;EbL>w`b?nsQa4W@4EqpP{SD$ z#>!#y9`oj9jhnn4na)Kk)(zq*n{HZu zX@X=hKjzw;W=(Ed+rb??DVZFo=A);%49uQJp7xpNXYXAYBZqkN^D`h z3xgu^D9q%C(7Zp*FJchL)Lq1-74qgig_KIs8?YB*Ig1u)_rVIZWQ?7OB>2=KahZLS zj!rrI6uSu~?;cbQ4rVP@=!lGGwZ3}(O()0l-?11 zKK4JEiTRt$HxGv<$h(8(@g0o0M?9=&sUuWp0_YWM5l>&ZtykS%y4{A8+kjq|Pg!wG zPAau|WiQ$oDw(P1>F)?$*1Eix5Ae!fVh=GEg zhXhEy{LJ15D~T$SBLIsKFCQPzDST=BgE!0_c;w>b2oJW+SYT<{Uy%&Tpml0gkTE)x zX6cUbbH(p64Y*A2CQ`rd+!kfY#xhj_D<_;4+k#senA)9(bj^pJOXiF z*J+A0$Y&OZMm*}`RO8qzGVc0{WCSfwFL53OD?IwE91xnhCnBCa@o>}gPAJ2xyW>~? z@EqrI?_3BAi~{(~cmyaDJE(s*8>@Jupt_1aRjnJ45$vSpM(u_?BQ4kHI(Tw&<`xP% zb5WKs`iKmv15VLM&^CQRtE{Lt)v%)a;V8y)NR%?bgz6{~>xt=T zK@CtcaI$Hc3?RA&ySyUcI62oi@t>Q+ii~}|z}C0=Q+*MI#3oc%wexQ&+?y;7%ehwX z9siRaS$r&S$N~~KUb;=A5x{4Kj5$E>_n|Se807A&?zcSmCY##oJnxoiavw&)Q zAi)Kf+Xs#B|CY0Q()AXX8-;TrCZ&N)&JZKbhjcAj`MGcZN~)akQsf6O#cTU+SD3^?d(=Y{wD{$-IZQx@@UU{Hr65z0hUakr#?&J5}io? z%li!`RL5p^I+j1TjFuE!)5ME51l>QcQ6s7E%w>-e|2do}Gxm$3@|9NP)2v<0fkbl)zHv zMCZ1ao_BJYPWgi9?{Er3Fyw|4;LV3i#UmdiFxQeyeOIMs+xb#nt#%MzdzC3sdEL+Q z*wNnN@z+Msc&~h>4ow7%C-@9AB9y>Tt8HiZ!x~HZg0kxn*=ii5+3Eb`An82lZZV~x zbNRm||BBqgS&3jT<=hEz+6KkA_EhX*LF73L44Rz%qMWpt&4VuuFnDXk6oE@a6*>EO z;5tD&4N5`^?FS#X>QhpTY0oZWT{s1u7d0Px)SmUP)JA0R1b49j=-LE#t@!S`_SY2h z9Zh9&*=IZ#w*pe!QO%uMxWz&Ay5E}4yI|o_8U|h20%{=6d+wz*!H`H+D`;KxLg>l8 z29RN!TDlEx$y^k_j(825d|ZCse|w0D!L9#OZW;b48@insbt^;md*arDuZ@0MEs@v5 ze5srhIemumF6Plk?d4p*1DJC6Hy3Z;#z2UIynX-*LdqLMvn-%?m zz+jCM@msRIgQd79qXNlBccdS{#vMRhn@4gZ&i=R7sLjr7p~DN@^m|@dT0C|&tXiW1 z5e`w_KEdb%bh`k`IP=M74|H`0NEp_7h;i-nYVN-Ir}u{hPQBNwxk?N~fRV4EnLWSy zeGW4Uvr5mkZ}Z>4Pv4TdKjgAAiwoAq36CEGKG$$^5jcz#&4T+t5cu3#TaTXH*(Pix z8qVI25#K)+>pjZMW^EUE;>9vAH0C}i>}MGzYl6n|V0<^QTUj5c^Zxl1|D>%oJpYp? z`)ZSpBM?suJgLvGcD$#!rmbGU)_y1c`M;7oybW?2rHqmo9p==f+my?itm6d7y!(rG zvg{gSL(2KWz)6f6V~~86IJE^`{jp?rLB%VZ(eUCzQ^zlsl=B1S4LD8K`e3!6Wu&YL zN~(Ya71~N%>7ewwBN4q^i_0aRv#-YJrM)O8-ubg0q&sHNqUwoFuTV(VF^FK~3AV%t ziqZ{E+G-YSt#VjuKS(|d&GiS5Pw<#Yo`FSphX5ZctJ3R`kx@()mn}20NjD`1~4GH^OM#us&-$K9P!6GhqAWIRKn5Wpjglu)U znlu32S;sk=b1X(8&xvT18Qo5V!uE8(Wp?K_QAVDs$Rqp+78fr-N>Du7NqXZ&vSPP& z>tJvrK6S2Xs@>c7+L8rupgMdmpe1^n+{mU$sSIIHAiT4qa4gz;l>h8~`vHV%=k6b` zvPtH16RJPv+p8mx1EZPXq4iY6Pz#YGi$~3?{k;5|MWtp_cSt%v8xB+_Abvlo?qK=Z zgFE%J)7AC!4~+pHK1E4G9MTF(Qx%re-#-^mvVvMWAW>)VsJ| zahz(!cpi}1EMywv82{67C(SO2h}W!O-G@>)=4al>T9n!z0p;dnywz#HOjX(!qm z>YrlUogrQudH+~7;!eMChLg?^<<%QMrCB2tq2bT{0c}i?X+m_(JljN+V?iEACQyKX zcERkEUhz8N5#l_-dzd42OCL9G0|)iT$vnL5Y0q2~TW%Nr07-=h<9NzIn>3<7DYyS1 zsHvF}s@`PX_xG^ z<(w@wqM@Z?L}~&1Xu!rOj@L=Gx6~|Cyvqm-$B;WU{to%`f3+oU2*0E>pA@&vZIa?C z(;=Lm+!-RgQy&^Fz7OKK2Ah-I{c9a;Xc=PEY37sVp4-IaF=%S#t2n9K%k$Ln6*AMr zKI*p1M_{a%UxztZz!`bWIUGhV2^(wGAe?HLU}a-XQ*JU^%Dpu0-)EzYwSqXd{!$(D zQ+bAqLt8^$twMn=GSc#|{zUw0a?NT|r2Q-WYwuFF)r&!$XWm(k)NYHN60E?S)e-AY zi#u4NdW?EUzV90Co31XHT)8>+5%sG>JWdKW#ivn@*Qfmkv-0=2>PiRk=q%#=sXeID zmCXFK_3N-nG;&mLKH*8!PRI)DADA9s-D`NK(6uUKCY;ZfhB1x z*{**6i7u~3fsG9CoPWlaKP~5~QN9wZU4pw6a^jm(een6YsIy(N z&RhfoPw@OV!G8qOY7NuoGdt0YvG0g-CwJ1BTgXR7=u;2&ZJv z1{@vHrugtZHrnse>7k<%y+;9cNPi~m?qIaBNrS`MKV`#rfszyGBIPWZk#=XBbPMzN zm8I=1h*61>d7z2tupqu>nmI&ex6<2+sPp&cA-Uw#2q9;NH4#TPAbojww?K9iJbkJn z=GxDWT$fBg18HEKbiNICVft{Wniyb1FYQkWvGN|*gi^+l9rW^_-_PjT{6 z%%7rRiKF{yoRac5HJI{F_RE)UguMpR@wZvpfJ7jo5SW207QUr$<}~D<#_BF3Sg*19VA*?w_%iL7OP!1DK${utXne zW=eDrI}nd?!P^gbKil$$9I{35$Kz^fVl&f83HUoI$x{{iB4fG!V7MN^|;3h(xl*@$;zgUnGVjg|*bJ|6oPIHGP`9`b#G#d@7*k6_SIj&_&o@Tm8|+K&?H zP76Z4qeIWXLVZe|OMxMTX8_1!yKt^_>#~XqCSCL9aZNCfi@>MUiz}!IZLUC>%Mbm! zR#`kH08)x|4LbK{!sMG6QTL4qnov`PLz;?Il2ipkIr;~4eATCV^^5`DGjp^A^tekO zgJf!}EE5s3m2bG4JWOZ(oBk>{B`Ep2WbtJ(G_7ybe2QMVNMhySQei8i+?wh3$H@WY z%QTwE;{)iSnk__rG~Oe@V18291(w-P9drW1-^tJLF|)KE!YT-<@zeb>jHBtv|`)%B19`vxSt+qwppuNuzplx zRq=o-VTpWbVi|bV6t79r1@umR+0LE=8G~cy_bobaFwbi{8vJzNDtbJ@v&BaW`E%|V zut7SznfDbZi)0TrKhueROEPzkd^?j#Mgs5|Rylu+JO25$)?gf5zIIpS5IDpI6UgTt zfXUi+1BTQeG|uO-ZwAbddn5xOHK|vS&JKXzwiW}`>XyD`C4xza1oCcg9c9%BLM%Jh zRffQ+N?WSyKkuDEMe-%nPl}(}B+!+GWeA#3Vc&4Kc)XcSp3M1n@4R7SfsMmN&Xwqp zHSaLebFyO6=}RNf*+qE6Bh-kkB$N-#+bA?F{sF306$U6xn{YMFsf~y-g%>kv*zXc< zc7;9^D~RUHe<`SWb3~Y=iVe{59l&DG$I^S{(K<>ht~|jw0jVpvo5hz_>lu;PNNIvy zHxIpf!ji_)(vaMW7`qljif4!dal^BHYXy47j@B=4i^Ld(AS$o;VG zM!K$Jq1i|I;j~_=Ldm=zBkkWXr3eWHv2aAoU7affh z^G{syn>~84$61*mX)v-9d;S&nq5A~;3Rv8AP-6JCWE9su6Hfur{!tKu@I#&-HE+1)CO91F9chy(zef<&$xfy zCAxG4!kz@K)js^i(Dq2p0tR%t{W!U6+1CL#hhF6U1w$f8T1}H%IribqAR%uz_)XLo zK!sb~JL{qn1oDjOPsmLm5VwaO1NsGGVZZZ+4S|F=!~L()DJT65ahnn5U-#O|H7$7lE#T57{PnMTgF*-V`qX-|w2t424h}~H9n|X= zzuH-*iM3w{dfrW}V!n6JqswBY z=Tkf$Z5|?b;9isjZs% zG-Ll5G$D6HtNL&3!Rt9l7bb+3?T*JDs08hr0eO)Q<@pY+bj?$>GD^#UBZ zcUb}PT3H40$cM&T_T&!B7QC+t7Wz!660qAU(z|A>`==dvSJR9EJcfdd?N>tp+=&4& zqXDw6(J!eL;7*JG_I-}=hA&e{^n1WANE1!fx0lp@?|p5}lr>0ezjm!G<{22p52KJ{ z))f=|fS?Zz=Z{UzdK zR5Zc!=pSjb9`&PpodAYJ{(_i{S~sl2+5(Jg^8VKFGE_02{3wj3dGMgluR{~_0U|*o z&Q=MM9BeaR^B;{muGg=_s=r#`7cYa0q*pvwae>PBJ#`B)FJ|~Us8kU?k>~#coVOip3_tC-`mZ8>xP13O*#Y+673KZk$rZ zd+|kyfa=LCqG2xZQASjs<0Mp=Xcw4}5 zA4!$sd-liVAvtr6;uua9p`_vNCmPjJi_vQN7fpyBt=(qzW8X!wSWwYTHb&`8m1249f}bU%2c zonL#~gIf#+MF&JcIg2#HyvbK}#TZ&RxzD`j*YXkldyy(+eiRq#mg5&2{UM~@{?N{Y z2#;XniKhY3!9d0Q|4{d)@lbzn{P3VsDH0hO8QGO3!VsZs*-2%mQ1;ObS+XZtCOg@8 z!q=AF*ef%(2-#t?WIL8!Mu58}t`Q4J$;l=cdQuxMAH9kR#1ae+v+u0XWlIm8Y&8v>I z|J-PllRK;{cPt`YQCu5QZnRPuA_B=k*sr5TvY7Zcp0Nhu^hCIS+7D_{JTm_9ac<+DObjyI=N0(HZSkW zvjvJv4oco@V{3M(bkJj0moSy4S20BXv&{&CU=1xr`ZOYtCD4oBoo@ghz%G3^62$)z zxZ2L&sa_izxTUn+i1KyTF(rqM!9E(T2$n1JRNzTz3;5U0WJ@!Mdeg4{;(kZjoABaB znOW;dI%Qv-hldhC__UF80Ny)*Nv9NzM{RQrTSSmCt z@HmWFTZL%62*e@Fk{59|)CW6gX=lcN`ZMONmX!CKG1jOcy-v`^y?5}QN^jOQlgI3> z0ySRvD+TPev^wSMP37EOc>xq2IVkdlnxj`fDxk;XV2Ww9TApd#^##~YlO@|Q!|O4I zPf=>iJg>{h=DQ`8$Ig&~Ao^RzZx9ysq*^fY@l7E!-nKWEHw*5nFtuBa8CE8&3w+tt zbouMznE);xir7yJMJ$K~nKVu1iH4U)BV6DN{2cpFqsmwZpeHhoOFEGwu3T*(A$xng zgFSLy!4AgkUH=QEdgK{V8Pg$Fv8%NF(L)5O_-|e+#oP?I*gMMtdg?)jUb&;cfamYe za~XJF;1NnPs-jlCov3jW)GPojOaZYUcx@?w{kcpVNV)cU?4%$bf|0)pS|(=)2IYG{ zNb0aMGF)b^H#8Gy&iA&tbd9-1VCr|3-65Ff!_N?@t-Xrkdp{s+&kJP{0fS~-#a5zW z0{aPWnuqVCu6*b_3xEBX{L1Y-%?scVK3R^jTl?M5f?jU;N)A}?s)DQDOw{1S$l;OE zP(sn9wyO!G0wz*>Kb+I0s5~pjTd#QRN_)Vk4~sI>;JQph&OP>w7K`p6M};xIZI|zW;41NQO@H8<4SHSfgj}olnWydY1S=N^~0{>X}DXyUjqdVq|8{QO;+^# z@ALdhxBxYA|=j`o=%x*UTC%8(7T~DpZ87f;iXqdQ3RWjkfoezE2K5h(&hfRMg%X z+68Ve8oF0hfw!Ky9N7?9sr& z#07f+x+brt$F2_`rWKPCR5Oxam#YB|@yHL0U3;So-f(RmYvxMT;E7WT@!+w0>GaoU z77VEq@)A)GyZvpo`nF2D#Hm|dEM0?L8`C36cJLT0(Zokg$#@n51^~2E7j!dPhEigK z&&l+Gf=k^*Dwi)HW9x6-nPUvUo@ZiCG;p7h<3HR3$R!wLdM`4LRVKG_PgVLW)}Or5 zEnk6v&q{?|MqTa?x08!`B$}wm6oh9P#b`cRH+3{TW~&@#iw?$;m*h#^I*_ zY$H@TV^l3R6pM5l&laW^hMxgt5AM1Ivj%y%us3T!?p|(6Y}QEzI{Z|;5l!J;t>|{E zhXxL;;vW&uRL=kVf@Jm7=@@e6q*UA~KEI%2vW-Mn=uIZQZ15;~IQ!k7xhV|)7NmBY z4BCj&40ehnQct0L$c94oMZXeb>%a+gkyLf~*cw-$29H0^zfAd$1v&6O{B*`Hxj+rG zRO73*^Uo;o>3FrR!~q7h6&^)6i7v+A6v~u;lR(O8d-$oAbP#IKHZW+lHa{J@8U6F{ zYoap*+LYG+;jMK{vqbg)TRU&!9~>!O*V5y*lk=5aHov#87x87E^D{?1`nV3oNoDFP zUG}GSSoM!ZfSOqP=zu-ls{d~wsjh;DGn~$aDuhXvx^nr;qOj_1Uv=^MY$~?5z-mse zQ4UuR`eHGNDL4XN`o#d;gf6E?#r7t+bo$wg^lHb?EDD-k`%t-0l1kG%<8`h%QQz`M zS=)e5J2cUy8>kgrb^z3#cQBM!|`B!%##B*{9 z1*l&m`*!A_7Eav@lxq>HVZ^!Oxcn>RLA5q-%^B*oTetEHHqBgI!@tm_)H{vIxu2l4 z;1tmu0uXq(rud6%ue%@RH%8fuE`n&&7j+?sNWG(j52xXGaf%mnMt$LSwsKV1%105- z<`PE5bV{rKZ^ypM<`ueJ1XpFs}wB^jTqxmJFaXVaG+Pmh*i24y?A6J0)MeLEDiJL|M617bVa zA?2Kh9osqH$!}CKL)GcfqPaJ#XSKBry_%AKCftPu0AQE*#1Yc5-A&{8IpNvFQu4d! zHca{l++ZZ9F@jmj>n{d#m!8iTez++tXoxb7anVGNAH15Jru8o)H>d_I zUjlU=IJpo-D)uGIajCe4(EPR6{3A*}dcir8c_Mcy8K{u0M z`gQG2evU}k!5?dg8VWESv+H<`B~ZZ&^hYj#VWd^Lj(}OH>3cmIjeJ_*TR|{EAQ?B2GK2P4 zlV}G15?m7C!3(uw2|Y8xcOVcQ=BaoMwq|ZGy_Kr-sysBGN%+NT~-G`lj;V=YK$TIbaZlSHg<7jRQGi?t+4N4GpF; zwtYq(41lGtf5jd(Hli9D#+#1{_0bApb|u~gZ0;L+L*6#C-GB>xvE0!7sDnt3gMj~@uY%`u;lRHG1Ja?x2lCnZcCIVAX8!kF6+E|) zZ!Z5?A1_$}B$+WjFX`=0!Cn2#b}MZ|B4a&-*Ogd%Z$b}`|3{F$oc+seBaxLpTr4eEGS=?gWZQf ze&P=zv?D?9o+|(L-bqRxIn8ML_<2afGaw*=5-W2kwA@20;r##2UDCJfZ|qJo__%@2 z1G0mNJU6YX`5TOh%lsI93+T^QbL2H%Cl;_a*H=IYJQ2facq5{uUe1+5i|F>5&x2f! zmYm-l2%d~a{Wq+5JmAVv^;P!2Z)R#Vb#|4#%1j;nA5X(008cH0^j?F08Hay$4Rv`3 zQ!N$#Z&b_l^$=^ehT!xt3Wz+49CLy_d1=M}->5EHLVtd{?>I=_7d(HZjvNpG99Of) zU;Ujl8~^gEJ@1Hme+`=0$ zid$V86(wVGSoRp470S|O3Q+C($K1xuEAn7EfHiZg&K-=ap?)wn-#g}}sG$Q>S3;>~ zg9xD8oGZoe8RO)5V}uNf>^~;EXM;ayaBUwAS7Jb#j=n&37z>aKcI_7l;seJZNYJH& zl#x%~CYu)KRl=J}Jf;65!Kc(rEY${w<%mUGZUz1_X@C z8oW*Za@6g{GXX{evE8Xl)L-$Bmgn&@x1q?Lt@{_e$**QA~Cm<$% zmDqtt{~Z0Rd%t~d+a$K}4lKrlI_+<1cD`gwfs3hl&XBcU!&QAtPe!cMMAnC7J zV=yjbM+&0_tICK-P)w2Oe=M44#iX9NerX|*$A-?-e!z$uSycrA_)WFIw26490 zb5DOq?%`XVxWKyUdszt~U`em5PTDCGzn#ahR78*BSfBMsOwoJLCEL#V@*H*|e5S$9 z^=Ys+6LU+V(Wo^a`CR14BsOao5hV`FNB)0P9{T@H5o%1v@ClW=qO+b5m_3;F|4%+p z1K0Y0{y+_w043z_2WtMf5A1_q6wVJ-?n6p?SQo*M|GrieL38*FtiNW*e|5q=dtW?G z+;OwbP(X}VOSqsS*-h^+KQy|gLi-hFgVFCo;I6Yg;`5KrqI|>oGu8-a;0N@OkCN>f zN*0WkYFwOoKiM~QbQ!q}^hznD^0~t=DB!3P{B`AZANa&C8|$wfzuGyH`EKM{+PJe5 z@f|LZHgA?^Z#uksfL&`efB7fTmny?T+Vtejm2xxMI1no!rQQ>-K5K{7Z4#>K7~k}h z;fm;(4m)wusz{HR3>yv}4jq0!94?zxx^R0xT%^^&LPMmQ+4B9Xz+@V~*CWc1*@6c` zF^$PtdlDY$UM9#$>Dji;oLY{%r$n|B6uxoKxnNsaElWh^6AnacBp{s?VUSH7e}bVz zyDmE1guh0O5kHB!nWA^u{*pbt{bhRwdq(>!_SF@}dJqrLI!(4km5CX5TUXHR9jrOu zCI)H`wP&`!YR_U1vu_}-Q1}Iwg+Z#dPTN$m3R$Zk3d$VIYGtlz6}>9*DDqyuw~Vu^ z(T7y`NKm?`omX!N^o?v#krm!>-Et@2-6Q6#`&+$`O%cBCDWg({z z>NC=tm9=jI_Hh$9HoO@=ZFO~=(m(6?DB|tuY zk)X89&1qH(k7|zD^{KykIUzjQKR7TrME z<`9FBgwfA9##>C)^vt5%tESw?a|c)V5^JsK9JE`tT6J3cON55}u2+P$^zz+XI$vv7xj<9|>(r3Mx@7Qu?N}ys~7$z)VRvJD_%M zhkMfpt8RimQ^!=sEV6eQS|MNTz^TK)JyeCRYyGjX8!u~jrR=H*OIh6zPE?11aQ2Vp zIkDB^lP6e1A6}s;%Bs+Wu#qt4xzW@4(T=C~v#j=5u=h!Ze}u{?6&T7`MUGgYzvayx zID5Id@z5^A&AX^`jLewejo>Y4eFem&LxR#&=dz4}>`mlnOC+AnIT+iTVu2KCOR)boT70@%u*V>!YMfX{MFZaV z;5&z7*cG}Gy|<04m|Kfknz=8fMEXtdSzndb^jdYm*^}-h+!&4pZ-h_UWDOR?U438C z9tdIjCPAsimB=%yvzrc0A8Im}<=xmB{W=?nT>0cuR^83gX4IC{HuifZdLDJ%?=;}I znksi1ey5D5qju)@m59#xSs9OPon=oZ&)#LFWz}WP$p66i+TsnOR364*H&b2XwR*`4l&d~gobruhYbC#JYqtBe<4`(A zUC46lCf1BpT|`}5^?AM3Ov01J6Xv!kYXM87c+;i0}R=T$vW>6Gdz*CLi-Aiac?S%S`uN_r>A49u$X3x%fp^XGZ z=d%8qyD-C4Hq>g;nuh7f3?+X0+6HmflnI zg0g_JMxDX6q;~qKo31EleC>$x`ykp)n3VwBrcPzzW_>-D(mN!F;X=^ACp zF}(b9F6dcV7&Gm))(3o(jBW<@JKxdaMXt_N_!|~YAxDx_s}2J~Xj9SR4x6jqjo(B(wgGP#4(m%Q>j>^Io(rDXC z(3;K$c3UZ}%;vx5AQ^BJPjbao>w(m9qgH0OSI+V~4xDwlRAu+C z)mWY2^s=l5366?v=YP(k9T|GXLu$sfURs{HB^X7Ml=2g;l+NKM! zX-A3WsX2S{i+Y_miXn;k6Rn(%j1fnEGq^(t&ed&M<*f4VKN&{X!Gs}i+vzn+UP|Q% zN2%|irg8lv$^vPWTk;+`=ZHo`eqrb7(|(U;LV2ufP5R^83#jWhEH)fCnN%q1RMTuI ztIzmG?%ujsvvK-XP5V{N)nHZRAV~-g)Rj-;`z}Ud6p$R~xl3hG-p;M5g~EQMi?Y{tvBZ5DQU0dwzdNk@ zbbG}O9S-A#2q?{b;AyKR{s?Mzoo+pJ;GD~byyrE0ap1sNEl62%#m_rr-pltv?QMZ8#;dZep)>xAvZdvBqYt{7MF%Y}ckKk-EoWxy7hH_Lz5^)x76kmIG<2%B^#C76#c*|2IT6=V27s`}W{rR0^is)%I znUS{BI`PQJD@ZTMEv~4pq?9?dX}r6i4#qL%#_0#dfvmN>eee73K5~A2!6`#C67j$5 zE@M-Z^SB+R8ToIKu*2T6-%q6Wj1dwL=74sTW{I=ZCz_~f1hz~w$}xrIbgO^={EEN{ zdmgroq9sAG#2%g26=)i4;+p{@420B+T{P;smMrIV_Z`NO` zdK&%RpbU_7A4hb|xvj)+#v@H2x0Iq7l8FoG7f@LeUfi`B^{ujR&BoCyo}*>&4?m<* z5$S-==}I+q4}uLRjN;4s&%k+_H6SjJdP&rH|*f1QtF7YM)e6t4L=TM?` zL>F|G<)+T##dxYw0+s$M>TtTWU}F<>kQ~Gyt36j!>lL;-C;ysCy+~M~BP*37@yz1% zO169Z>78wAKMjsN3GCo3F*@s=!%IsRDuSfT*eBE3;}=x*jQa0o2I(N87~__h?KJ&*5(-;S{c=t3p2;*g`>Nw|mbQtVAcyj%Ra_Z<PlxKQbyqgEJzLJ68UH2Srp7F5yk*vPj9c%?0JQ)&B+&}#)@17eB*HZX0&Hpp=(;F z@T69R0b$Pc-Qd13g&5 zf|OyKt3zjAO^J+7g6?(9W`6QO>sd|v>&M-JsY4qgVUg`}^tO4|xR)L&1Myo`6hflQ zy9RR=yGIxBx4EiOe3cY8b0gLc(n-KCv0yec^CW!r{y^G2-{UR`+CZigy^a?yvm5}H zm-ppB)SzEMWoPlCjMb>KmFsQQ5?M4S?AgBe86g)mXXbcR>WQY{;+Ii)3bW5UZO+@Q zA(25N7b#Be`B*9ZLrL@1OI0?hFzpow%azIz1TSVXkauPlDFE?%tyqFYS9i_k5|{B0 zr3nVN1^*Nj2MVtkO5wv!Lv+&HM>SVnr5a=P#trOV!0dBRn+Z2-F*VS@(UugDftzTz zoAL46PURC}xlgLgl#NDq{n`-8fvcVRfpkX{EiHU*ZMUg7g6fH8Y6JP72QCl{I0b*z zM=Py<`C6U~qk&}jM14&*Sw(yCuc0jqq8y9Dm|V<5YV?T~@t54cNX~7lqw`~WM%-3eCF|#)kXQ-1oYd{!k{DFG#SXB^AUC_Z& zu+rW90aHb#TssfwfLV05n(w&61Tl;8)4Yt8?LOl6{v4LgR9$w)s1{MlR9+cTi-NR7 z^4H|zd|z}zDd4I@nKVu>r}O#)HSOwqMI_8seAv&@4G`_qz$FUc*_*yM_V%Ssxu=PA|kbF2^txbQvQi zNO%ZDA-7@s_(@uF+aqC0r0P&cWoezrpALy_C7M4UhTg<6et%iAf0G6B=~AbyHIEcB zlgEudV-K(2oy}Cc#Wy90Wc4l!^X~mp6q(LL^LXqWk`;1GvC~$a|M)h!*XxPCwz|_# zYEgeaOKji6^v`C)oY0mKzv~aRx^xe%j}eBe`&Vyv?|ig6&VBql*n==4F4*p~=~6Dw zeWd2T1aINvOHWOhsWEBdiou@kHJR3?(1j6=W|*pwa)|JfQGAK5NL|D9r1Us5U z_pDvHjKq$Mgd#QLrTQ>fCmL#zDl@mzE3Bj?1A;>~BT{HO|3b zlBc07B>kkW!O&<|&MFu7eZ(dW{$S2@428U4S{7#7+Zx}c9UaKG$d*Xd6oS0RY#P9E zzH>0qeGH@D(&@Z}#%*IR|HtC0HYMVysAJO!ewAEdHe;2fyx`BAt?_g=0 zwCvF_qaM2Us^k@Us5-QT1-swhA9Kk6Hoj>(4*MzdmrOOuM&Q?UN2~gsufTuuPF|`A zPm=q6&2SojmnmuTYKND4Xwt}@+!IGj)ufrWXw~>XcMa!&+*Iiuhc+v?ZN*jM_DkMx zbI3az^XRG*iOvTJFa_1>GDgjp!i%I2&EGqof%)i2&zABXYm4m_xmq@#mDI@kSc{!! ztU?JR-aT}%mrrm^2Da(X6Ce+t9e-Zwp3I)Rptfl1rtHi*9?$vKz4Uj@@J64bRMra1pv zHiZxpl6jnMMX}MlV{pHFx)UsV8|hbVh z*pSBxxqA(F6U|kjwa3ooVOSf=`cBJ^w`zN7WRB#dhzEi-$ylBqdX_JiE`+3Itg2X8 z`QT_N#Nwr|edrlOrporJijYZ}DdlG#RPvkK{Ah*bzT*8b!|~fas!7$lvztzJ#}pY7 zo%*_m+01n&60Vl@XLnzS);!F52WsJb|12?=aW6eZBP&O<#21nuf*y>;oP4tpq7;kJ z>}XKCGrE-?0GbL|B{^{&F2UuuVhgup3!o>720>fM)OX6i7f7al7U`&pF!fvtcwdq{ zKbdMUHg8DPi)CW=Q$Z-5{Oa0}{R6R}l8MujU$&YbRig^TMqtJP!v4NTRQXApj5yyV zJes~2R}VqdHN|&I2nC+0GfUGvV|M;zpIS$7mM`>V`y!Cv={3+-e!=L8g9R-9en5l3 zRTuu6`X-8uGo8BjGw_&{z+%`}rIwWrFE#A9twwM`mWj30ZfHXz%4IsY=pf#DKA|Ld z1pXD4#?0gt>%x%pS+VWxXl(4|s#p5MCl;f4T>nok^)tqujU`Tyh2%{JobLw7{{d{g zj@BPPflK$)a`qPf;AkRnu>@0koI10A#!r?XQ5-8BV-gq$TUPSJdp{J7cv$Yk-76vV z?u_e8`wZ^GiuG1G2&l0VN;(PS(SBxo#)CdXq%DRrTNx`g>+)&ISHxl3!bT_E@nZQ~ zXOl@tFLahW)YnI$@vJ0oq5^;VD{tXSp`=V0pUAE+#=`elY_Lenwz|C3oFaq-Do|G-ti@!s+T{v{HSBO$b z8*koeSh5ya9!8Vpf*c9|8nh$u-^nf6vx|mBHN+4(DM|CP>&Fj z&MR=q^SZ-ygP33K<#0N?+RwA-W^!9OCXBd~BrZgzXLA(i`0;@9bc5Vgw{yCl@%2sf z&tdbqJ(EcnKE_s<0*+jny&md&m!!8oXnxA(y*00;&}?zZn#U;Kqu_V4aX`n4s^K(P z3n4!074~#{^yObVM_?_WHG$zQbNKYG?tf|Xr>i#Wt=PG}#Zv+&T9mol7fb>2n(cvo%Fq030;qn@u4$cQ^$Au{n7GT!dg z0ZA2`O~)AToqbgZ4$kSeuJLT`^{bZW+^{}1Z4yY#ow1rh z)HxL_=#FtVmBz^9>{mu8#Yq@py2(aHg?swubMxVy>Jhx7E^qEh+6K*NAK(W!-+W5) z4EqIhnfLRGHN)P$1|;Q&x&)$uv*G&uoWpc=ehk4lbB-!U&~t=R=<`_K zoN^sr1FaS7QhTZ{YM;P=fx#1Foz#3Yt{(j}2c{`0c2)nJZt@P=+bPVE$kMobRn?mN zuyue1t_CshjQg%3B(c zVVa~CbKb(iIsDJ#}QEH(xn(kQXE-`PDxbwR2d*z*umFyDmk+yqMJfXe~ zSC%*FKIYb7AX}#U-y!L!qdV1Qn!?O<_+7*Pd*@1Dc~iAK>KJ*^4s@zv}eUC z6;FKL8h-&J{ISS|+wNunPJK|@o0ZJm{pyj9N$ek33n)g-NDOEHakNY}TRt4Z%)5q= ziSA=(4ML2+)-_PQbv9j&&5;$h4ZB|R+qBt{+~@WRnXncRGO2q6k|I1_y{6ANrghp% zC)AKhL~@}<+}w11rB;bxUok02&Dvz{T3)|Xe^$r5i_0z@rb0`9P-rsAPe|y;u*Njl zoJ)5*q6E7=kCk7WTk1@Lems;S-DxY8AW||J>!`7)bX>BPg}t^0m>>917PJLEgQ-@x zFV!^Ygw(I~(IfZhk+GE}b!28XLnhJ@%G~9Q$C$pK&5GmXEW~TuG#Vi1u|5<}%{n=C57QrP zo{v=T!$2C3J&KRf(x11aEn(Jwy3!p088w-Vt@ApgZ|sw04*61Vox)P0?BafES=jCU z*#jft4-DofRG(}*;l5-V*Emcw>z}dQW?J`ja&KpjNUO!r&Xw20!Vrgu%{FBZu?}AO zbNU@7JA>u|jmKN6zT9B{U91*ah*y(4-Vr0yVbIh#xGA66VZgqm;9|wwVp+Bk(9*+2 z&nxhsfCI(46B)bNG^vay89uZR_6466ejh6fyVv|DbqF)_=k_o!#35`Gf4uo1TE*}3 zotwwEf0fQmZ#PfxU#Qk?{!}58+8~b{uJfU2(e3%$4)g?6iPFSJ8V_uynl>AYojQ8C zSjv+!wXZZ@=>%05ratu}*F78b*f=Y(<*6(hetB7cwZ`V`{?tyTk5r9*osTZC+89qp zk5Tk~T6{g*Q9;SIRBDUjc?D}=%kod%EtwQ#dE-+%qZi8d$~((!tfCUw3xd*h>C|W) zFQpCNPa%km*c-?9vPEJKUlw}*T4lO;PwsGfNO=Cw^I_g!IY(%q(6UT%Kt{pG#*Y{7 zi?6+~V>Q>p#}Oh-(~ZRzsP(&?JA~)(M<>+xJ*$dIatu%kY3w95lfnig-E}bZ%72by z3(8`>gyaYQ46eu`4k9w(cVzZ*FHelDGHmt9cIzElH*s~1FF|NBC5?D@Bv7YgACAu_ zh@~NTrf*KIyQU`}sr1sNj_>EDq{Tt24-`!+RcL_qVRGuJDx(~uuS;ef2`=^#sy6Td zF~{12+`*3eo;uR;s_i;L)9%z9C;)m4pf`_-TYcs4s7nNUIq(MN^laajmFBlaJRZ||;b{Ch-AJ>kf48@}&9 zyLm7JMH5JZX9wXA=4hsq#0S6ZO?>5pcWp?mIJ(HLr%feAs1L`Njtl#b$5P>w`%@_-`?Fq< zPoiTnmvO%DFc5=KOdQ&pffqDhryqi6fu9oC6F;2(0myx8x4ue^!ss*P#$td4H0N2~ zvlU_cP)2bhLXH9JPJzBU6V@p<7mXktyd2Z7pI$POOZfr)(bOxvq5{Q%61C&qN)WKd zo$O$$wap3NIBS;ro5rriAD;iRXKTI?v7OcRcYQtUHlDInp46Yk!E-NG?^1^y9a;%t z9k(AJR{luMok2jt)uol?D(So#PZDbzmt>J9%!n0FudjSardbkY`}1C$oXw$==3I>N zz;3FwcWx^3flvNh{crpCQSvAsXl{TgBlE+6x%E&hEpCT|tvJ`$kCuYa zua(`OYD82Xi-XpKtb7q#?=jnnqzIw+S-aHTmb@WJi*t!zbt{_swbUp4el{7(Eu;-K ze+24v@Jsqz7GsmwX4jIB&Zu40+(_^9T2-HmCrDNJ^wX9)9O-B!dv9JE?;9I7IJ72l z{M2?*c_vd?s(Z*OQtwTTzG}Kbm!#L>G-E2Y#JjRSG(%L`=-9pC#c&5`JY z)Ln{KlB?+1);hL6=b0CyAZ4_49mo{#TZ|t?f0Hf{bK&-U&_1hlK}SUot>e-66rUKa z3$r%GK_-7E*zEFKA+^#RjQDz!R^8~dLz{;4k*4Slkv2^B#kI!bEjj0XpF8}qnXlNu zmFuAv?($pFyHejW^zMM|LomgrN$t6G0wF8HP-V>?w0`_2L?Jd)oXwJQj^in$QUx5# z5?HV~=c6aTd7A$bSz4GbK1uxXcrhn3e#q_Hx!oFcQ8HpbuQ3yx6RJ!n86NS66=Sn~ zZAIBIqm$ctbRT2x%Nh~*xqjAi^FS%$`qE4ym+5uBSdp0~mjFJ5jZlPFgs(b)zO~eN z17#{p^!A7KO-?lo7E{&bHA*5zVHs~#!700!9$Tb>bW;Y@Y7?gxwia_fxfuQ6Z`#@~ zN?9198t-G%In*7S0qwFh$Qs5FkKJ!)7^-MjA6sit=fr&Jx4D~l=%z13`8ub{*S8!h ze~$6w-O!Yul_}a&bU#?@N#(H$vXH>=m1HBh*E_f+?5@@0vJ`-}=#IGyQOnrntz;^U zsp5a8cKoY;wwlYpAYjTOP^it6&dc5oK&~1g+Y{>GkUfeu4Kx zQKYrT;bQ1!AB*0hE(^~0W~C4v_fjk&v+r|XF&+S9F<%ZV-!YyzN)H#qK4{r{TG$u+ z6Zwt_8CBSuhK0oNa9S;-_?!zSnQ<72=VN{;Ys9a|_FDSW46i zr|q!=lvT-$)!Ozd8|Dd^@jDh%l^?(&DsQ9*3@!Y!>PAc7yd&8RX7C*Oa73aQhVw<$ zo;Bboo-(GwMkw?$=F5<6H{Y?kGj{ps5EJ3!E9obs z5)9JRpS9`wP1D0?>PCgpHN+;dJAC_00m+Oq#Za7FgnRHAC|vq;#;(0?zhf-L|QuWO*=3#SQaQ#fBYpi<8XPycz+e2%ty=ZX3pT{2WF9>536FR8B2> zSJ;0DsaO61JI-{CIBxfC@hO5Iw4t!EhJW=XFPy=M?q8~?p|zh@cb)5{@3?1xUJJ)_ zsmm zW92u5%y54DC4eoCW}{yCkaq@c<&X^dao-{T@mJ9# zfQ1Es*P6KhKDkSN(n|zS{!op!tt{%;9FmWT{qH>kIaS++D2m9$In51tHTCmID1`{p zqc)KmtXH#T@z(cH8o&2q)}beo08+_VwJ6w7-iSce??I$=4n2Fv0*Y!O?TWv1cO%pr z!kU^1OJg=Ee`R+~)gE3>qGYG)jw8uwku6Iv&+|Aqx9*|mz!(O+Wx&xLLy|+v#e^Z( z4nCraV|KUSzz~sCxMu6(qxKtSDaI7S(Q_&r>F;V2wH`w$Sm^$8$dhR)eOU!5L-Vvu z3=w)#2}WgaD&V3y4OBa^P~X!QbkTP^RwAGf8CrLLG2ze?G2D+ov?!6jD_ih$Q}E;s z2)EAR%}j5;W67UEV5u+uDtz?2Hw73$Lj+)IgpuGKqhU1d{v>ny6`W7U#LTvi%es7_ zNS=PZ>czM@g$EGcA|0 zN}boKvzxhdulz5QWG^q79A>hOWmiPs?OOS@@UtJ#Lu*wBwWdGrT8gB_ahsl6*x^6s z3nR&08q1@J_U>BgU-)?lsN%7zgBq5AiXTkr5!9HFJqa{XaFF=flnSYD3)TG24#;v6 z0d1*G=K_o7w&{DvFdrY4y@Qz@^65#HeRN}=cYh&cCg=hZ74uINUTs4K=?wat&)PMQ zIb@3jC!b%Xbq$wi0-@vo9L4{H;Z-+O_~+{qlKW2b{&pyDJGrX1EmD)69iRZ%a(9O3 zF+*4s*bM+f8gq;M&Mikhqr3sXwPM;~y~WCMtqAPrKb6Uk1jS@SLrR;z!#dzYA`j9QnX>dT=LlI^FN&uiJ*4nOkd!-^g&EY5_h1N#9 z$ccpk2loyc0So}&6*y}{NB3Y`tcEum+}UzSaBrHWYyIOymgp7(-9wf82fO=L=FSHw za9QWiWGic{@eU%wf@mNROT`U1{mQIr$HbhgHTf!kH+1rCF<>#rE<+@)p^*dP0@guv z>^mLLK{DYY`3jGy0k@}qZp=S{_$~h-Ns6bze8-$QcLnzhGrP#$|5X`~n!JD`E8m6; zD4ss>;bT0Z?{At&T6RXRVg7F_NWqwm7wYDWyB`K74H@`#Z?tVz{6hNn_Cbf){C4$% z<&bViJ;gCk>Z33*d`;Kwjs%K~7mO!SKaP)iM%Vt;W>&`c)3Gb5T1Cn%(`B0kt8Wk; z&d`%~Ti?IR(T9$;n3F^T{o;&WE{YA9%hPB4l5`Gvr6{TPAb*GC`iP@l%Pv<*k$ZBl5 zHF`ii{?px1`Sqr;T^ z>5?lad#W-suD)b@B062>&|+*@$Ck~0rrOF30uE5Pu4Pj~t0FSlo3nI$BghyS9O+2e zTP{gmYJ0Cm5fFp(p>oF$P&%6*z2I60c5Yr?OF_=BwagN>5mrfE=)H%x0Zma507LR` z3diQh32C7vWmiha_X>gSLYT7kRmj@|$F6wCA>`?Gk}T&z{o$KMT>;j#R^@Z0Y6tdi zY^RH75EMOH2QaE`Ta`TxQ58r6F!7UhWnvqyn-6Ty+WIEHV*5ko#@if8m>)X|^Pj5< z@Dfb~hCB@pZmgr>B@_}xT>Z~R90WF^ZAB^j`>;7TlzkJVS9v2Sx0Y}3@nPX#qwt=b zi|bN6`t=j#-}mHIeoG4{3PW=Ejz2L%Hz->2qkubjgrvRx*Px9%zVrn~@D2}~+ie<3 zq3|O`6hCxm5DAI9T)lhg_{9nRABDo)nc+)4_B&F_N%M)t9#jcJp>ox#8@ik0)A@^q z1aIUv{PzCmXavV@`I7T{v0;YX8RI*UUn(1IuKdbPDXkjFZDlVqsIWe7+??_wyX1RW zzt{9{=p2<8DWf@K3Q1uaWVC27q)NqRB`{ zo7QjHM`;g+C|Y)P4$Ig>C}xd!i88|nkcE)){^+mD{Rdew0LY5Kl}2-m zt{BI%hb)(X{8HLzi{^i)CU^; zU3=r1HknLg@id!F<5R8XcqrBFTqDb0++14Be0^?!y&}Q|U0_;7WWYYI64T zUc*kwjt)OCIEG~tUaZW*K5nj33+_-1^AiPngmT&AeWvwN=cDRTyhNix!$s3gRNz!q ziIaoY(jP?eE2>B+4vyoiHcM_b0^tL0FTHCM)3`B z?f+u$IpC@8-bWfLDG5bsATmm_QYb`-&>$*NWJE@xGFmc{LI{x*4JnbCk&GnC$d>E| zN=f$ppGW_1@9qBXMe;V@?)iMa_dD(x&p6{b=XuU^o^q88aJwYaKi%JN`ejXh#|P~~ zO`21QDG*Vb-SYz4j-Z94072J#(q3ge3yePQhrS>^D1&n zc0%>bo=h}9#U*&BRI@K*XK?6PYi=z~e~P23v8Q?-L~H54_0+QWB3g;fLJ@l(CFl85 zsjq97TgqUaq*%Oy$v5GC4W>0wywVXz$=LUKs>%Ad@yY}5c5gp=9jzRQxF#0{2e@tP zqc8p3S(asc?O~-SrT2c1w@g#Q?`bcp*-*mhhsK#hSG$rHyJY{rx&|qAf?mv#p|M)_tY%DN&-^@*6*-ioBh2dPXK&vij7l;2nxJm;3cg zKi@|hvf^$nt>fIaCZ!^bD#mj}uEuj}>gn*-J*RquMT3iocWU-;5Np=365BG-UiqRqs_Fpc|?E_w8qJNc2aDp#2Q#Dx8_%P*-3 zC!|EJ*j76Cid+9UG?fjVgDy0=R9{dF*t0J6G+&0c}s#{9)~V2GZKe1mTn=GcEYoDxSQis&iR&f%b+#r zd#Ii(xaL}Vit;r^N50FvgCeb&TQz4la>py4H~`U7eGL2$Of*{4E_a^6xBdB>YpVRF zXe#t6l^K-8cM91iOkJFiaxS=}C)Kmip@OnWwq5QXL%?E=sBH>9bhx~-LDjG^ZS0A| z+iSR_-`ZX2TdC=Ai4K9dq21Pu{`S?trk)+VmGK&Lsv^~T#!au~DwTZv>=4_+?JYbn z(IS#iX0z)0`#$tyHeWQ&SD#TVRh*#xxHZzuJ5i;uV#2hE@r~c$?vE-q~4}@tnzCacUJvKe}`mz z**K%iqOEm1akZ05acbpRtSesfryw6M6srp=-f?U{@@7^MeI`zW$x><`!_Dn|ZzbPu z#i_*#Or-5;C}CHq-R>B)x3x6KL7|fh4U@7Ba6!EbQW+8Ff(MnMHP?l zwMnT1f2;r#t;FPqdO5olxI0DYpU54ae%Ma&?&35wMP+8qG+_!hcdJM!{1og4)*12h zFKk?IX5AkuUU9s!FmtPvz%)dWGrFCv>!zT z@|ikF+X&e;#9b5O53_XjPY7UfDUTE%)FUxvg>($JIqTRXuepsSX0)`t6O?NPf9X| zt(mj;xZ&k4y*ms^OV3~9iX5xKC^*$mHi`wkM4*gNTtv zQB%+fQ>{PUOTFe|T+OU-*Zr#D#FZYvWzE7f)xZPe4|s8~yk3Zt&X9A*sT-vv9NN)K|fsP4c!2%Xy|EIVJRx zXxkfoebp&j5fJ*wh>FJg)0H%N`BU)%mmH!*(q+>`H}(lkcCCM;I3u6`sU=TOM(RGW z%(_zlKtOUmW5|`mCz`1@DIAVQ%U+$6AIh|6b8(%i-_oQis2$kMD6~I;MUKftZ|jQo zdSu7dI?iBZX3d1d=lit|7T3 z^s-HgkjuNmX$J?5&HNebrhw(zMO5&q&b@RABISa`Xxvv-U6m?`qNTjddL{%mb6NI< znS95jkKDGT6PZ>leKVy<_0t-U|5n&IMj^Go~{;pacN@2+{qhR~K1 zuv<%Ce^u69t2V1(Vr3Z<-xDp*lS^8C(}SrdNcAEsgPlg5wEal(w_+%2FLChwJm1!h zMQ*2wUT}=m&V76u>*ElEBr0ykn-?F*c@ZMc;BeZruE&S{sbcR97U^J!C(m*vcX8&$ zwydK)dvDiG+a9daIjs>jGzHhUMP8yy`HU(ih@6oq&q^^2;a6gejM@zX>nCG%vZh0RHROBWcG^PBS8MY{_!UE-Dd8t22+F#>|)bIq#Ou(pCnMGyY{+6?+$?mTW64d}>}Vh4jLU6?}It zm^%}(NwM8dndgOKuRn|2J`=rtF@C*|Q#stUD&9406k#mh(0edq|BO4!5H>z_y&GKu z+jLV;T-)567*(7lTp~ZECvx}Er1y7PnzD{18rIaYu&W65I`2a*KZcb zC`?J(k=Um$b*-tuU4yx`dO=}hoV?QsL^1W#*4@tC)9)E3vnbv&(aVcTYd18D>$;k; zIM>5mZ%u!UHOtl9a64K`)KtQlCD$bmYp>;400Ys-t>+jXP^6|8`ur+1Z_JaVVnhZ7-Xk)JkVHdoJTDn~jpK)dL??@^&cC8q9R( zqH}0;^QHfPf#N zyd5%6o%N!ir5P>E2^@0$xadl*c>l?3Vl2qoJVSVzyYOX?@CU0odz0FAm}4B;rxl9$ z_p`E~YN292kFCvJyXC3W`gaHGXV0lbXRMuN(PAk+*%ia>K{sz%YmEaZYQUDfhf~rObLo?4656N0t z_t|-pJtp-V4fUpZM&cM5Os5CUUZzu#I=9hbU#I1@FBwTtC-%tQ@R~{e zS;ON`m4qV{gZI}z*uR&q{&2=tj>9X_yg=!gZvV_%W$zxpYP-6N<&-kZfeps>i679( z6wK3FcWG1i(d7h9Lv_OIk_N7FON({m9Fd@qXm{!Gyo${|@sen$^JkgqB-`6{Zud^( zb?w>gTqP=AkD}UGy&3N9-YOD?;_6+Zb(@&B>V=7p@4$RBOTF|vd)e|gblzL{rSyID z3C}Mc|7Vv1V?})ERS%x5t~-wcs{EGmR%EA0sNY@vCGv*C>{Cgh2U%OQRxrkzE5#gb z=3uF~y14Q7mzdTHrTL6CjINTLYoZ3W(kB#OJYM+JQg6;o*nN@FwXyV5PS`>27ZNMW z+^0OeY`1DnkYVRNl`mQAyspoWUtpB=D!9)Gc5@^sv>6M>8qY{`c%6mZt4m}py4HJt z66$&B8QwZ@+s(pVn{~4)J}XQ3%sF8z$?iST54OA6(^=I6dLG{4{&uZ}4)3zYop#sl zmwGL@^<8t8(7IUjh?uBT>q}Lp1=cRqVUO`Rl{+&p(PE6` zQpD5M15Gss?Ko$a(lWHIrAR+pWL)G1;Z)-z;!rB?yK6xQ4R2T*Zo?Zt1$XL96E zW2S7aUzreBUYhxZq~>Ng3DbAtGsPU(&}N?~clA&yXO-ZC-S;A6(S{b48LTM&wrb|Ykq@)Ia|Kw^I_ZxUd#H2s7-8rj4N+>9gCzI)a237mUr=Jq>QO# zmb=*vlX`D%DP<+9u%#l8CW}Z+J=M7^#qh&beU-U`jXq`V!teyaXIE!dcZEHH-CB*e zwT>2@65oA`h0RI&Ff$WnThL527zEX=l9)L&A!06Wd#|xb%JpdXF1?pQrIRm7^lj|4 z9t6VL8+Wz52njZ?xS!S!FrRx^7+7+uK(f>!%4AVccv?dIEO|Q{r8D~nBiFZiZKXjy zE0dVwLhFRU5{{>p!e#6FG^sc8T{`P{& z5h>d2UiS@z91~?tjV!!#w{K5Mcb^du?Oqmm%&@cb)|8mY{^LqLo#!du(2HHq*}!jb zfX_k*x6%Ljz6_s*m;BQ#C`<2k>Ez{Bi&9=vJm=;g*kG}_lxqO(ud9{SS*b*U7wGb+R8->f};v{tkG?EYpcii<9RMGZlZ*6@oQaHs3z zLz}rfWz+6a-T9wAfDAZUv>n@n>b6tB_`FrTrxMMnq^6zI^VhDo*vJ;2lTQ!(4=FVs z?mKQ!mlW3=Z%&n_CSz)7aXNRk-OJ7t#oa7X%O+=_O>s3UGnA@%SIJSNdCKw|TDay4 z**R(lshvv7nYEx{vafUxeS#I6$PQljeHn$^I`uEg;)E!*y|i}IiRe=lF^Gla(DEB} za9eQYOK@ihFQgmaW|0s})8OByhHWx!DBu(+X18dz5Ycbg#d6MrN;HUVI$M}I<%_t0 z6V~G#Xz@0ek~7};1CMPui=d;qt6)u_o$t&Z2C)xw zO`kl<7rs1oeCIBDv(%)tca^t3sEL?T)K53KFZPyyVh5i^{VvlgF0@6Nb$pk-TBQiJ zevmiaqcf&06i(bvtt?~fPbWp!#65E9O^6C*t#rQ2@akqm59fPhD-YJXtZ8Vam{-K0 z;^L<{GW^<&S!eE=EPAY-mVIhmz2UKZ7jXtVx8@@|IaaAtFwehOZDV2qhWhSxrEUmM(k$W)Y63()e}VQt#XAgA6_{<>9J5A<@L??_b}--R8mRF z)+q@aM3+c%KmEGtxhM3Yd01bh81Fq-Ig1_aH>+7GtQI}hv1Bwp#E>vktW`;5Vn?>{ z<((_JCg!LF99NItDc^VN+-+K_xvqkxfytuB+e}K>Ey~xJ&U=M6jpM9zN*=rCQQoNb zXlON^NAbZmx+GA{n$dWs*l9ER&5wmI&s(|bozEPz1uYq;JYF<3QR*1hnFtv?xtLrb z^Yl<$z{EGZSX7zjUU66JjiXkp@!nv{Pr=CfRLwH1O7o^_!wq*+{%dZqIsFoBP9G=C zpgoDgjlm#XOuEekZC)>)EW%cdz}>M@J6quv9Ye(&eriXtOijb5r^OZTxdvKA1kk{$^nXO(apLZ~ZP4qmAX?C(@T*;ih z_vzX|8tW}aj0w$~M3zoP9moDx6p_9m$=gLgqFwv@r57kEWE`2{Mz;gwA0;` zc^YynP_X9o;wdSLPFvyHyM0THYd80e9V*h36KkH|z|M7T?sqy)Jr>d%&WN zNzT=rPN%Hs=oVY#aGRcC7Or%zEjn5)o$`$J<$}X%5`4EUKT;dbjH<{m$%u4&y}*U_ z?x}#qPRMb?Dl40s`EM(;;ntSArqkxgo5A9_Z)iVk zHlOHltRC`)Cc{mCQpL5#@1MB7Aqn%;Y+18JQyEh_{Vq8fBfUe&e8 zO?z3pdM0v%F?VHOZw_+`-yOd8qhX2-`!$ay$Z;bjY~_80xwn{Xh_s%)K- zhv$O_%QGgP<1N13@X_M_{YSUPhb_{*d_nY=^zgp^D3QRt67LD*O|TM zw;lAwVG+d2eqVf4y*EYN&)nl7B1p`{o2~gXW#5% zdilx1pY>(QgoiT^m$w+Uv@#5w4|Ww~HLy+4Vo?g%M}5pQYl({k_iBFrgwxge4co+| zcf2l{^vNaJsFSyozw=pZYgy(6_voa@w`NQ?S#(y7UXzE-E6QF^X9x09!a@I*1vxcI zes;0!xc*XI61Zv?S&`#3-10UDuEtD-Mo5w$c=rJ5|sm?um%C4@aDKzqK2i{h5D zSox+JU#-R^-IoH{Z~Jyp_Zr2u3AqV~=y`}`XzMBq#QEtxIq#O0=_1HzuumkeEp07= zR>0ePi$Wh?-!Ai+Phed_7e$Xw2Dt{%Qsv8qvE;CwP?{Ax>m z$(laX`|Iv+xvzi!V8;wQ+28`}(+jfb^<1MenY)WN+M9>fncT8EJb~8-0CGfncpj^D*_y%3@?DM2V@&5L{ z6Uc+lx2}D-6-<*9s(j8%(oy8QA@3c!TdnmS)sS1E@f`QNbjl4Hxy@Z>FyD0UcD4(8 za_U9dx=V@*$CmnZZ4!GoS*K-T)}oS~El;VQaUlOivM%jH7ANa8z$;-09(Tlu(@CtE_*E`<&Y-vvShkdo0AItom-JQ=lpLf16S6hiv4|xoVQd8T> z(Jo)vwWD)f-DFDCKIiK$(A7QfHd`mcWYMx&3O$D((|lN;prU;)4SB}NM(y$4Yh5Zo z0MXi z)5S1PV+@vVVp-aHPX#{jG2KVujle9HmbEw;VdmOYU~xB)!~CJYBF6xuedv+xb%s{$ zpWD0ad!^~~FFV|?b&V11?e40!aksf;lV{WVYEh4?MCdza>C+rp40>(?SsGj86DY1_ z*4%%In>cmK;r8OK{LaY9+dm%c-~~AmWbPh-J94#1VGQqqv=s98wD9R`KyH7(32N#x z>eYTtOj$R#%Q$IkJk{ZN9Z!j3NUzl1Y`Mq6QrpOK?=C%KlRcK&GG=-Q_U$pXvecF{ zw30udtF5-j%EC-eMNZq&Lhqj!GhHL}VzOtK<({t}`St%5t=m=S)UlXxGcjr~y;pngj&y zJgn17LBVuo&?#d@>qT2fhRoz_Nx|reNxVs{xpp+p+H+(z=U;YqUbAq{cqOG`CZVS5 zs?(icjhn$?!%O#!YwrXE?KgHdA%YRm$o!&)e;W>Hs7wO?GeN-#~Y%GT?G~VbOnj7wVr|6)< z)U(Rd%ClV=^?CEQTeKa`aivpTLt$A~_W5O(*P@&L;&UloHecB++!O!2>Zsyk>bJca zG%wqf#Xl;_T~x5H*zNMX>$qlarDR7_Vq)acGOr^$JNd+Mw#%1#*TqE9zoqHDnaEgU zKjWdq&Y2$toO38SubAd84V>QjI`!U_1qCMd9^yN=&IHP`eI9pRYo|m{ZlZ_ec~h(K zxfkR&uaH?H`s84&x@!LF!-l~HAC-&e1>4^hv(DvE6rSzGvWl4{2C zfpu%9!iG<{q;?*aK71AzR2(3 z=f!Sfe2zPVqb=7gTU1wm`bgHvdhc%BKz&o0)TNM!0sHRmEDDywfj_y)w7m zv4#y-5VE=89d5&lgB21kdDQWIhgdt<5+CfeKhUtky;fOk@p`IphO<^jGS{gjEwm^+ z8LM2$xt{8RwoMazKHJKr7DpmYK7E|DI#g~kORwAI#jSy{ik|FG8#;FNZ7%Rq3UBpf zcPU%kDp@4NTiKs{Z0CO7_W5cQ^W-L{)yq@buyRv)EE@mP{0haYGYpQ|=cwL`e&?UO z+orcM^K|<>+(cj45l&fXs3gp{SI)X(p)ZHkdvUfsa~c4Tux~`Xl>cQOzef8-o=mS zpIATh->tKaf`wOjuO!`_+zT=DpA?G5HBKw{9G@U(pheAF;C|6tnQYTZ=E@E z9>!3#lqu*=T)NVe`T3@-gp2HZUn)g=C5_M{~-x+g>%t#r#=_lb|YQl$Do zNM2}`Hz%)F>3d;y&miVqnfknZ8?vkx50*Q6ytUo9SIW=qqF|o1=zZ~L@{D&h zUw%$*O7f2KTFu%(-FQO&ssP_BsjVv)^Bro8(3!bscYzy^A$@&xuuw}K_1O&*4;5%x zddLY>a$h<-a})i-^PCNZL6KIm9k&i>P*6rPOui(#>(#Pe&Gc}StJ^|W)Gj`?%05G? zQv5)Pp!@NHXr`{Y<0!av_<7zIFFdlM-Y9GTna7-~799L2z*C;XeNtomqXixz+6oi4 zyzCm#se8Wuxnj)%;SWqHo339u$)zjcW|*BIc1T6fSk1gX zJf0_|InFyHkWFv-?Sc#W7Z?}UEz7n&^?7z?va4*B`jJenh|MmC&(`zm&frTpDiE{m z;=q|HQrZhPsdp`C`ha%G^-u=wqMSLK>%HXT{w4i%5B##)^1H>O!yvy8s&4MsSl?$Wvthkn16Sj-o1~L92)HGGr7Dxp0zt&s$LuHvo?5X{6*QsIeQNZ z9JM_*{r0O@_DgCL4$)on&Yu;jmA<>S{`MZe+a+RjAL-h5pO4&fO7Ds8v9%xX&0}Sh z5@VavpU9o)bis_PP^j+$_a^QxljjG5$9?MI?p)%tS%G_lO*kF5N0g~>w?OkYnvWdT zceh)|3yLmrT+%ctx5uzU)e6TXNE`QbX`}f`l`|2*Bz4<%0yY=0f`GV!QpSVfa>ajp%e{q@jHf%8dkHrFo^I(17$zvFFx zSdPGEIoq>(6CPG)dzw3|lur;iEe`bGM0m>^GiYzaNh$Ccya zubt7d-qCoVL|w_xRW#tp?w1c$822SO8o%C_Ut^=PrlzQMJc~_^Rq&e~L7X+u9>#2- zNJ@Ai^UAbOLV4^?T^!4(_ws`esg)eb4m<{lz_NK-siQ~BXpv4{W;RxjV{r@R@%aRTJurO_|oS0 z(;r^0+`i(fpmh1;chs3l8;;~Ou6JGU_+V;?(B>9BK^Cr6t@ixt*Q>ABOYd4xoW0+E zL%1JjsDjT&Ryl^-(>_X06}Z!oI%WKH9kpboM4!*&rSk>-Hq?lg&U?}*u?7j>>@@RF z=4dnZdsi!QB$dmLZ!UP(q2z2duw>F(J>IyI-l@WMbL`V>)|@kwbc(39407nMzjcDn5~-S@N~ZG{XkV|-kF zY5I#}omGq8SMIvB^3_7hn7X+83$OJ*Tyiw=)OE%Jd-nY*>$ZM8Nn^chO8eC`gQ?|9 zYS%s6(cGUAzZRXWjHcBO@=wwUKH)s7;;?oJB0z}DJsO_be(znW9{9akH&vb1m%n4@ z?MekE2C9G$H8dg-kqi=do4bxH#4nS`3|wTsHQJV6-X?R}n#dCFU2hvG26*H3f9hGi;?L!`Ex1Ede<7q$ zZF8Pp#LS*;;^K=J(Nyj>xH+{)fq{q5?CJ-h1=V&l*WVhioo98vyYuR*g}iSx6Rcz$ zCsIG>;xUY53?*n6cciAl%VNoduPE6@D2pN7Oe zmJ&2BxO-Qg=jh?OEv^rbnb{>8jq{{m&*T`q-b6q3nZttq*)y6eoV7C*)SYX%rm>pO zg+`TBh*}<6!vFb0`<;zE zN?*jA-{nS!EG}u9TvNEZ>fy0?ItBSs zaLXOXpP@<1mf4(~GTS%L{mQ+%^ScHfhZYnZG~lY;JRlu)pLP0*;xO*w%g0>>9U{{6 z=ce}{<$YGV-==H3#0CA>O&C+lq-fF#UZnX{<-a>28}X30q4|LJ9LuF1JUdwQ1#U7w zon^)8HvQ_6d?sGi>9Tyu)M^S>6V}e{qph&yp6zY!M>TD_&h+iPiYHU18ib_1J3KFR zcJ*HA$(b#?f*)t|U+lGtqdNAm`oSWN%OC9X46KrQZK;E~80d_-o3_7Lba7L9F_$uv z9rPS)zGmqjctLl|02&ScBTxC#Yiz z^)N9N<2Pa(>n+X;oOt8MAQiT(((p3(k%?`a+uhk}O9C}%oE;viaDtK`i-nxf9dj_+#aqD|3}fsl21rgPS;AC_E;yxqP5*f7<7=Yr<5z z{WC3Ra(=W+H_thsH+P0TQ>J*&rro!c=H1?ZuFYhX`{75W)szfwhLoqbJZZ__bGAv` zs$o~=)QFR7!#DD6u=Ni=a6ZGXV;4W2Vc7+CujYhp_g2l-;VKICxFH~^8qev`mZ&!4 z(zvAp>&v9C?UtK&J1cKXiQ=W|`g0H2Cc8?#*jlms+VZp<=Elrn&yI$iZZ285c4aW_r)`Xt zAFhO`p0e-R&zC3Wy}#IErEmLu>(^%$d3$yBHM{v;!fL2Z1+0>m8QTQCQw|;u|o|r7RA2=5qSvc%l= zc)&vs&&1- zi@y2n_fHFkolx-coH)U&y^T+2TWC11*6VzOl?henm+M4P7-?i0nABc7SO4U)1 z==|6NLRuE$p(|8Q^6#Ff$opbX!&FNCX8XK_qM5n_*Y3W)skzN^qR6X5UV65Z{X`=(Rx}U{n)2;Eb6ldG(x{t=|f_}Xl<;e!Yg7-#FK9eqa zyYNQw9Wy{{jkJgD?K2h~Yb}eoa(c0icLVzPF3|7=Oacy3kZ)RTLfTXyjjO>MMgoTQ_w_F`bt+pYjtv(1I`B-6M* zj2E{qzPmC0M%>(prtRZGl%`zr-|BgHO3iE;<2~b!?A7_KEy#LWtEg-0touF<-nP|g zZAM|^-`{(j-qg7Lch)ry4S4Yu*q)ZAf{B)-K-ph5BrbxZ@;oEX|^PSe*NOpVNq7$LHde5^ra4*V=q3GC`s!Kadpw~fk-;{LM z>oZFN8QRt@NspoM#64Xjzp>Ulov}T2<*p4Itv5eIAGg0!>)k3+UbyjW^@DlYowSSY zGCxjOIl%d7heSb)?Tr~d%j8Qt-&~E*P2TX%hH-1bhOLho?#SG$&6y-T@lK zsCPfM&{AX0S2U-uL&a1s&D4p3} ze}1Ct}1yc6{%= zn0N!w8o#mCszrB_|F)3)H-2St$1dzJnI-kX*Lb?RLP+hMQ(X7T=6t?#`W97}x1DnK zQUzqQ6IA~>rBOY3Ydq&36qUovGv(M>zwZ0HUJ6d%O=-5xR8fpBy7VHj0l1ciG)SqIm!L;GKvBfN|J(o$!?`PJE^cl>L5;ju-B|xs^3rtMEW#(v%{q zJN64sIq56%Tx@GYRpF5M+~drN+fvR->mEsG*suQBS%A;+fpYeIC*eTf4qS_p!BLL@ zwRNcaX)7^>*H52$fyHYFdy;b3t#>Mm!)MBj3mbnn%OUX^>#D%E_5G=xRa_m$8Mn2) zmoMbqJ2BpsO?<8!m!Z<4MTR(^#|(Ej_uZ~F3ZKcofN@e<{==#JSI$#C^g+Xu{cQSi zn!33CFDu^MQN6zb173plFw|UjMSZEjJhKfi}8_ZtwzOorb}%76q8;ncQ`hx8fU6> zb5Acxc5|APTAPhLyY$nBolfpQApGe;;&rbAgX8h1>%+XwLsxsU6RgEV&XPJ=G$EeR zSUu|=Rae~LeXwzIyRGr7#D`@U`wRhOg!ukdS=+MTounJjA;OL;2Ixkgb{k<_-L zr|9aM$!RrG`qk{gj&0tCXQ>`7$Tgg)t)RaAfYaWyRPkws36HENHX1Csu23ZYe$=e-T#A1ikMpc%tvCv8Kgy-az21+FgU|Y3Y7?4n>zA)lSRZ z^4uprptT_M$r4=qD+_ILv9_2!p6nYlvtC6xgr*i0K3Xr98yLI6lUg`hXN$qr_5W-E7V+QS(|rlk;^|4;^P&}Cy}gK8g`tiS21+TJBBD{AO07wm32=Dt$O6+ z_p!+#=|fIt?JLuL(p$Fd&?pzI)687HrO3O)LU=$^N{Hn`rXoXjyimF5{wJa5r$@&$ zlm$BEFTEbux5c=p&@Fa_&rORJQfoJDM3%$RaXaL8HpNCt9!kE{Dasj~np*CY8p!-Q zrkp;>@NQH+tyzmpVCMabn>=!TtNqm4AKqbIw7;Zo$~_zLcVSF=OAa0Q&?yv>Byn9^ z)o#rOElsLbEIDP*2VR7ztlAb{v$J-Mig8SBk7td~=P8c^Uq(ETYbt56t*)_&6@4Lh zE?fOz@J<`O%;_HAr}VxMqoe`)ls>Izv{9{>MUa@upaiOMr|wDx81 zUI$D2>t&T?BG>91Lyu<@XeqcR{i7$~Lf;(G103ih&9UUoER8)8+B_&M-1qB_+*PlxR2?+`F4I4Hrx3aPV(~2R3i;D||oSdAD zn3!1hiWMsc5Z`g+>%Tz*#M1&ea#cX>@gUakp>Z#+8+o;TjqmaE*Bk9gQTQlPM~5VvyW%E3AyBqY?Ss;bI{>VY}d_FY|Fzs4m${5L5O z5D@US4?wbj6RHnsz<*R92FLgRCe~!EFbceX{~jkUEaauQzyvn|L@^7F-*z)Cq|XO=Np;-e-E#TVLF!lkB^TZ)E9!z z@ZY$Slaq0Eb#=th_^rQHRaN~f^uTBw7Z-$iAf7#ea$a zHa0dS@%)?l59=Rf{{Q~+AGFnfBar$3_m2NipMGclx3{+S9R2G1Yie@aRUPC-FoRIm&MC-&dEb?b1T ze;Y}ttgOWC*s){Ouw1xs;hSQ9H~vH2K5^p2sPP+X{=U{)5ipN9j9O?f=c2H>2)@MuP+BzG0s=u>gG{tlf+zU;H$`JO52h zO@A22vEu)wOP78a7JS-|#Y7{4kzl#s7l`5B@MLBjGuI z9OO5JDE~oL#E&=H=iiyW5nW(&hW5x0S*J$JfAF=TqoXqitoi>VHbY@yVPndF7=K0^$owb0{y)_CPv-wW zHEt~JKZY9r$?c!GFG+Iy|EDgJdLsA##3@7W|H=J7skMqg5VENtTf6#%CCV(yw_JNVYb2R)vaNxjbKp9L!Y`qMFZ8ZE} zzkWTgySsZ-@Z7z7mn2<&H~xdZ_4@VexT2yWTyb&nh_2k+TpT*9h$N0c_tEelYz*Ap z-3RS*MhXk8UxMCfD0{-u@E>r1Ibcps&WLCN@B$4ZBO`~jC;Z*`kF}ApzK@3gV+{kA z-)Q-dr60@tui!tN7c-VPj3h5oHg{uTi#U?<@#E~?y_;x14*Dkict7JAWbu*gO&~U& zjaDXsa|3<`Zun(HL_`p!`N@+fe>yCO4<9B9OKfcHPlpBeJd7laCI8Q!J^NGf1lyVB z=4PU3j)g5cemxt>b3#G_QCM1ATfd&QGm?D9BF?E(r-;Hr_;D=x-_p{8L+1+qv^sG5 z^l3tLzP^La{hmF0ei|&WuZM8o(pO;6!};^)e;O=swil^(fe#Zrk0t*hKR8!vEbPFC zYXjES*2B)5pxtR}YyazX-@0`x?)B@}fIi6p^H>K5hkqRwbbjBUFG!Mr5d1OL{D%Cx-(YSHHe1j>!Dq@?zyWzcUQo9#U%otIyH?mA zmztV7Xy*(53&#o;$OqO)U`{(+JtScNn74nz{GlI)x<3}z_xQCWgah6o{aC;OdHg6W z0MA�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 From 93efae093767a19613ccd3756ba69ed7722483c6 Mon Sep 17 00:00:00 2001 From: jshackles Date: Sun, 18 Apr 2021 11:54:53 -0700 Subject: [PATCH 35/37] UPDATE: Galaxy API to 0.67 --- galaxy_api.zip | Bin 18488 -> 23018 bytes .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- .../galaxy/__init__.py | 2 +- .../galaxy/api/__init__.py | 0 .../galaxy/api/consts.py | 34 + .../galaxy/api/errors.py | 2 +- .../galaxy/api/importer.py | 102 +++ .../galaxy/api/jsonrpc.py | 198 ++++-- .../galaxy/api/plugin.py | 581 ++++++++++++++---- .../galaxy/api/types.py | 136 +++- .../galaxy/http.py | 43 +- .../galaxy/proc_tools.py | 1 - .../galaxy/reader.py | 2 +- .../galaxy/registry_monitor.py | 13 +- .../galaxy/task_manager.py | 15 +- .../galaxy/unittest/__init__.py | 0 .../galaxy/unittest/mock.py | 10 +- 331 files changed, 19756 insertions(+), 5302 deletions(-) create mode 100644 plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/__init__.py create mode 100644 plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/importer.py create mode 100644 plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/__init__.py create mode 100644 plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/__init__.py create mode 100644 plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/importer.py create mode 100644 plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/__init__.py create mode 100644 plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/__init__.py create mode 100644 plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/importer.py create mode 100644 plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/__init__.py create mode 100644 plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/__init__.py create mode 100644 plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/importer.py create mode 100644 plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/__init__.py create mode 100644 plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/__init__.py create mode 100644 plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/importer.py create mode 100644 plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/__init__.py create mode 100644 plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/__init__.py create mode 100644 plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/importer.py create mode 100644 plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/__init__.py create mode 100644 plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/__init__.py create mode 100644 plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/importer.py create mode 100644 plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/__init__.py create mode 100644 plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/__init__.py create mode 100644 plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/importer.py create mode 100644 plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/__init__.py create mode 100644 plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__init__.py create mode 100644 plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/importer.py create mode 100644 plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/unittest/__init__.py create mode 100644 plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/__init__.py create mode 100644 plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/importer.py create mode 100644 plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/__init__.py create mode 100644 plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/__init__.py create mode 100644 plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/importer.py create mode 100644 plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/__init__.py create mode 100644 plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__init__.py create mode 100644 plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/importer.py create mode 100644 plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/__init__.py create mode 100644 plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/__init__.py create mode 100644 plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/importer.py create mode 100644 plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/__init__.py create mode 100644 plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/__init__.py create mode 100644 plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/importer.py create mode 100644 plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/__init__.py create mode 100644 plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/__init__.py create mode 100644 plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/importer.py create mode 100644 plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/__init__.py create mode 100644 plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/__init__.py create mode 100644 plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/importer.py create mode 100644 plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/__init__.py create mode 100644 plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/__init__.py create mode 100644 plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/importer.py create mode 100644 plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/__init__.py create mode 100644 plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/__init__.py create mode 100644 plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/importer.py create mode 100644 plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/__init__.py create mode 100644 plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/__init__.py create mode 100644 plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/importer.py create mode 100644 plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/__init__.py create mode 100644 plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/__init__.py create mode 100644 plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/importer.py create mode 100644 plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/__init__.py create mode 100644 plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/__init__.py create mode 100644 plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/importer.py create mode 100644 plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/__init__.py create mode 100644 plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__init__.py create mode 100644 plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/importer.py create mode 100644 plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/__init__.py diff --git a/galaxy_api.zip b/galaxy_api.zip index 492e55b314de275334a66e928a6559cf064333ae..0c512dc288bfb03bf53132f2cc9851d8525e38a8 100644 GIT binary patch literal 23018 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 literal 18488 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 F2LP`FZqEP! diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/__init__.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/__init__.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/__init__.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/consts.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/consts.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/errors.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/errors.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/importer.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/jsonrpc.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/jsonrpc.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/plugin.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/plugin.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/types.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/types.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/http.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/http.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/proc_tools.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/proc_tools.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/reader.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/reader.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/registry_monitor.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/registry_monitor.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/task_manager.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/task_manager.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/__init__.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/mock.py b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/mock.py +++ b/plugins/3do_9d81c0ec-5646-4b1a-b809-e7e61e1d3577/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/__init__.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/__init__.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/__init__.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/consts.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/consts.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/errors.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/errors.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/importer.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/jsonrpc.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/jsonrpc.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/plugin.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/plugin.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/types.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/types.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/http.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/http.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/proc_tools.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/proc_tools.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/reader.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/reader.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/registry_monitor.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/registry_monitor.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/task_manager.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/task_manager.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/__init__.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/mock.py b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/mock.py +++ b/plugins/3ds_f6acd3ed-2c31-47d6-bae4-07b6714c1e55/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/__init__.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/__init__.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/__init__.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/consts.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/consts.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/errors.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/errors.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/importer.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/jsonrpc.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/jsonrpc.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/plugin.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/plugin.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/types.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/types.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/http.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/http.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/proc_tools.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/proc_tools.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/reader.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/reader.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/registry_monitor.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/registry_monitor.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/task_manager.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/task_manager.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/__init__.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/mock.py b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/mock.py +++ b/plugins/atari_830528d9-e621-48e9-8ed4-e03a4853843e/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/__init__.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/__init__.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/__init__.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/consts.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/consts.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/errors.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/errors.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/importer.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/jsonrpc.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/jsonrpc.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/plugin.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/plugin.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/types.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/types.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/http.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/http.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/proc_tools.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/proc_tools.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/reader.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/reader.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/registry_monitor.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/registry_monitor.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/task_manager.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/task_manager.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/__init__.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/mock.py b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/mock.py +++ b/plugins/dc_5d181ffd-48dc-4330-aa58-6f646e76a5c8/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/__init__.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/__init__.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/__init__.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/consts.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/consts.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/errors.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/errors.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/importer.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/jsonrpc.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/jsonrpc.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/plugin.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/plugin.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/types.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/types.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/http.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/http.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/proc_tools.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/proc_tools.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/reader.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/reader.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/registry_monitor.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/registry_monitor.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/task_manager.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/task_manager.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/__init__.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/mock.py b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/mock.py +++ b/plugins/gb_4345afe1-a2c3-4c58-93d3-373c53a90a92/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/__init__.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/__init__.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/__init__.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/consts.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/consts.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/errors.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/errors.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/importer.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/jsonrpc.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/jsonrpc.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/plugin.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/plugin.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/types.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/types.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/http.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/http.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/proc_tools.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/proc_tools.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/reader.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/reader.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/registry_monitor.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/registry_monitor.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/task_manager.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/task_manager.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/__init__.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/mock.py b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/mock.py +++ b/plugins/gba_16a78ef5-fba6-4629-b83c-ef47adab5aab/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/__init__.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/__init__.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/__init__.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/consts.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/consts.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/errors.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/errors.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/importer.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/jsonrpc.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/jsonrpc.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/plugin.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/plugin.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/types.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/types.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/http.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/http.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/proc_tools.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/proc_tools.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/reader.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/reader.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/registry_monitor.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/registry_monitor.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/task_manager.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/task_manager.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/__init__.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/mock.py b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/mock.py +++ b/plugins/gbc_9b53fc85-af7c-4ce2-af31-0d95234d783a/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/__init__.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/__init__.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/__init__.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/consts.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/consts.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/errors.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/errors.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/importer.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/jsonrpc.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/jsonrpc.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/plugin.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/plugin.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/types.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/types.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/http.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/http.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/proc_tools.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/proc_tools.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/reader.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/reader.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/registry_monitor.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/registry_monitor.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/task_manager.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/task_manager.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/__init__.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/mock.py b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/mock.py +++ b/plugins/jaguar_b9773549-9c20-4729-b23d-f683762ce73a/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__init__.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__init__.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__init__.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/consts.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/consts.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/errors.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/errors.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/importer.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/jsonrpc.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/jsonrpc.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/plugin.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/plugin.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/types.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/types.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/http.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/http.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/proc_tools.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/proc_tools.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/reader.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/reader.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/registry_monitor.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/registry_monitor.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/task_manager.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/task_manager.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/unittest/__init__.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/unittest/mock.py b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/unittest/mock.py +++ b/plugins/n64_a3824d31-c2d3-4a1a-b321-7d0764da5513/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/__init__.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/__init__.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/__init__.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/consts.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/consts.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/errors.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/errors.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/importer.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/jsonrpc.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/jsonrpc.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/plugin.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/plugin.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/types.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/types.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/http.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/http.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/proc_tools.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/proc_tools.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/reader.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/reader.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/registry_monitor.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/registry_monitor.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/task_manager.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/task_manager.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/__init__.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/mock.py b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/mock.py +++ b/plugins/ncube_602422b9-ced5-476e-911a-7fa0adf0f7f7/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/__init__.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/__init__.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/__init__.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/consts.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/consts.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/errors.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/errors.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/importer.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/jsonrpc.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/jsonrpc.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/plugin.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/plugin.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/types.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/types.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/http.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/http.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/proc_tools.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/proc_tools.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/reader.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/reader.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/registry_monitor.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/registry_monitor.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/task_manager.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/task_manager.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/__init__.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/mock.py b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/mock.py +++ b/plugins/nds_4704ed29-f516-4fd8-8477-ddbcdb7cedfc/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__init__.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__init__.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__init__.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/consts.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/consts.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/errors.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/errors.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/importer.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/jsonrpc.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/jsonrpc.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/plugin.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/plugin.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/types.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/types.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/http.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/http.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/proc_tools.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/proc_tools.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/reader.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/reader.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/registry_monitor.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/registry_monitor.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/task_manager.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/task_manager.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/__init__.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/mock.py b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/mock.py +++ b/plugins/nes_e2c630e1-3cbe-4dbd-9235-5e6a2d2955ad/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/__init__.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/__init__.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/__init__.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/consts.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/consts.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/errors.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/errors.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/importer.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/jsonrpc.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/jsonrpc.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/plugin.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/plugin.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/types.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/types.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/http.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/http.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/proc_tools.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/proc_tools.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/reader.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/reader.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/registry_monitor.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/registry_monitor.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/task_manager.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/task_manager.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/__init__.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/mock.py b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/mock.py +++ b/plugins/nwii_2d0e97ac-0406-4e5f-a85b-ab5b1a042cba/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/__init__.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/__init__.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/__init__.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/consts.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/consts.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/errors.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/errors.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/importer.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/jsonrpc.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/jsonrpc.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/plugin.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/plugin.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/types.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/types.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/http.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/http.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/proc_tools.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/proc_tools.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/reader.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/reader.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/registry_monitor.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/registry_monitor.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/task_manager.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/task_manager.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/__init__.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/mock.py b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/mock.py +++ b/plugins/pce_c0ffd4b8-41c3-46b8-b0f7-5f4e4bafc68a/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/__init__.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/__init__.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/__init__.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/consts.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/consts.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/errors.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/errors.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/importer.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/jsonrpc.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/jsonrpc.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/plugin.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/plugin.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/types.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/types.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/http.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/http.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/proc_tools.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/proc_tools.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/reader.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/reader.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/registry_monitor.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/registry_monitor.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/task_manager.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/task_manager.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/__init__.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/mock.py b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/mock.py +++ b/plugins/ps1_ff02c67d-5962-4e79-a3a3-928814edb270/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/__init__.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/__init__.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/__init__.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/consts.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/consts.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/errors.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/errors.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/importer.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/jsonrpc.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/jsonrpc.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/plugin.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/plugin.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/types.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/types.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/http.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/http.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/proc_tools.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/proc_tools.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/reader.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/reader.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/registry_monitor.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/registry_monitor.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/task_manager.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/task_manager.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/__init__.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/mock.py b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/mock.py +++ b/plugins/ps2_50ad79eb-393c-4f95-98ce-59f095ae47ea/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/__init__.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/__init__.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/__init__.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/consts.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/consts.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/errors.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/errors.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/importer.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/jsonrpc.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/jsonrpc.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/plugin.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/plugin.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/types.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/types.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/http.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/http.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/proc_tools.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/proc_tools.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/reader.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/reader.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/registry_monitor.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/registry_monitor.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/task_manager.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/task_manager.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/__init__.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/mock.py b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/mock.py +++ b/plugins/psp_05487532-ba29-411b-b799-784262d275bd/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/__init__.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/__init__.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/__init__.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/consts.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/consts.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/errors.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/errors.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/importer.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/jsonrpc.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/jsonrpc.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/plugin.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/plugin.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/types.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/types.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/http.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/http.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/proc_tools.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/proc_tools.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/reader.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/reader.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/registry_monitor.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/registry_monitor.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/task_manager.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/task_manager.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/__init__.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/mock.py b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/mock.py +++ b/plugins/saturn_bd6ec091-8ee0-440a-9e26-71bbf21c05af/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/__init__.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/__init__.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/__init__.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/consts.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/consts.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/errors.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/errors.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/importer.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/jsonrpc.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/jsonrpc.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/plugin.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/plugin.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/types.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/types.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/http.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/http.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/proc_tools.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/proc_tools.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/reader.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/reader.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/registry_monitor.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/registry_monitor.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/task_manager.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/task_manager.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/__init__.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/mock.py b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/mock.py +++ b/plugins/segacd_ec7197bf-a4e4-4b86-81b9-38ea7d56f3b2/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/__init__.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/__init__.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/__init__.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/consts.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/consts.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/errors.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/errors.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/importer.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/jsonrpc.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/jsonrpc.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/plugin.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/plugin.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/types.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/types.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/http.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/http.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/proc_tools.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/proc_tools.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/reader.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/reader.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/registry_monitor.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/registry_monitor.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/task_manager.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/task_manager.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/__init__.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/mock.py b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/mock.py +++ b/plugins/segag_e3ac94e7-945e-459d-bc1e-676cff8173f9/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/__init__.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/__init__.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/__init__.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/consts.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/consts.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/errors.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/errors.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/importer.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/jsonrpc.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/jsonrpc.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/plugin.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/plugin.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/types.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/types.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/http.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/http.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/proc_tools.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/proc_tools.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/reader.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/reader.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/registry_monitor.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/registry_monitor.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/task_manager.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/task_manager.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/__init__.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/mock.py b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/mock.py +++ b/plugins/sms_c6689bfb-7ba4-4d24-98e3-bd2dc339926b/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__init__.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__init__.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__init__.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/consts.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/consts.py index d636613..e29eb98 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/consts.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,12 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" + ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" + ImportSubscriptionGames = "ImportSubscriptionGames" class LicenseType(Enum): @@ -128,3 +135,30 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" + + +class SubscriptionDiscovery(Flag): + """Possible capabilities which inform what methods of subscriptions ownership detection are supported. + + :param AUTOMATIC: integration can retrieve the proper status of subscription ownership. + :param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True + """ + AUTOMATIC = 1 + USER_ENABLED = 2 diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/errors.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/errors.py index f53479f..d5424bc 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/errors.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/errors.py @@ -20,7 +20,7 @@ def __init__(self, data=None): class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): - super().__init__(4, "Backend responded in uknown way", data) + super().__init__(4, "Backend responded in unknown way", data) class TooManyRequests(ApplicationError): def __init__(self, data=None): diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/importer.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/importer.py new file mode 100644 index 0000000..f958f32 --- /dev/null +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/importer.py @@ -0,0 +1,102 @@ +import asyncio +import logging +from galaxy.api.jsonrpc import ApplicationError +from galaxy.api.errors import ImportInProgress, UnknownError + +logger = logging.getLogger(__name__) + + +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete, + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def _import_element(self, id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def _import_elements(self, ids_, context_): + try: + imports = [self._import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + self._import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + +class CollectionImporter(Importer): + def __init__(self, notification_partially_finished, *args): + super().__init__(*args) + self._notification_partially_finished = notification_partially_finished + + async def _import_element(self, id_, context_): + try: + async for element in self._get(id_, context_): + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + finally: + self._notification_partially_finished(id_) + + +class SynchroneousImporter(Importer): + async def _import_elements(self, ids_, context_): + try: + for id_ in ids_: + await self._import_element(id_, context_) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/jsonrpc.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/jsonrpc.py index bd5ab64..51ecad3 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/jsonrpc.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,42 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + params = error.data if error.data is not None else {} + data = anonymise_sensitive_params(params, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/plugin.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/plugin.py index e2330bb..9a746d2 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/plugin.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/plugin.py @@ -2,16 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum -from typing import Any, Dict, List, Optional, Set, Union - -from galaxy.api.consts import Feature -from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator + +from galaxy.api.consts import Feature, OSCompatibility +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame +) from galaxy.task_manager import TaskManager +from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter + + +logger = logging.getLogger(__name__) + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden @@ -30,7 +36,7 @@ class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +47,85 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + self._local_size_importer = SynchroneousImporter( + self._external_task_manager, + "local size", + self.get_local_size, + self.prepare_local_size_context, + self._local_size_import_success, + self._local_size_import_failure, + self._local_size_import_finished, + self.local_size_import_complete + ) + self._subscription_games_importer = CollectionImporter( + self._subscriptions_games_partial_import_finished, + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +183,24 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + + self._register_method("start_local_size_import", self._start_local_size_import) + self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"]) + + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"]) + async def __aenter__(self): return self @@ -136,7 +228,8 @@ def _detect_feature(self, feature: Feature, methods: List[str]): if self._implements(methods): self._features.add(feature) - def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False): + def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, + sensitive_params=False): def wrap_result(result): if result_name: result = { @@ -149,7 +242,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +252,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +303,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +324,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +355,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +379,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +401,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +410,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +422,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +465,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +481,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +496,161 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) + + def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None: + self._connection.send_notification( + "local_size_import_success", + { + "game_id": game_id, + "local_size": size + } + ) + + def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "local_size_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _local_size_import_finished(self) -> None: + self._connection.send_notification("local_size_import_finished", None) + + def _subscription_games_import_success(self, subscription_name: str, + subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None: + self._connection.send_notification( + "subscription_games_partial_import_finished", + { + "subscription_name": subscription_name + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -458,7 +692,7 @@ async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally - in the previous session they will be passed here as a parameter. + in the previous session they will be passed here as a parameter. Example of possible override of the method: @@ -480,11 +714,12 @@ async def authenticate(self, stored_credentials=None): raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + -> Union[NextStep, Authentication]: + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +764,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +878,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +899,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +927,162 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_user_presence`. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + + async def _start_local_size_import(self, game_ids: List[str]) -> None: + await self._local_size_importer.start(game_ids) + + async def prepare_local_size_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_local_size` + Default implementation returns None. + + :param game_ids: the ids of the games for which information about size is imported + :return: context + """ + return None + + async def get_local_size(self, game_id: str, context: Any) -> Optional[int]: + """Override this method to return installed game size. + + .. note:: + It is preferable to avoid iterating over local game files when overriding this method. + If possible, please use a more efficient way of game size retrieval. + + :param game_id: the id of the installed game + :param context: the value returned from :meth:`prepare_local_size_context` + :return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined + """ + raise NotImplementedError() + + def local_size_import_complete(self) -> None: + """Override this method to handle operations after local game size import is finished (like updating cache).""" + + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions' for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[ + List[SubscriptionGame], None]: + """Override this method to provide SubscriptionGames for a given subscription. + This method should `yield` a list of SubscriptionGames -> yield [sub_games] + + This method will only be used if :meth:`get_subscriptions` has been implemented. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return a generator object that yields SubscriptionGames + + .. code-block:: python + :linenos: + + async def get_subscription_games(subscription_name: str, context: Any): + while True: + games_page = await self._get_subscriptions_from_backend(subscription_name, i) + if not games_pages: + yield None + yield [SubGame(game['game_id'], game['game_title']) for game in games_page] + + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games import is finished (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1102,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1110,27 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() try: if sys.platform == "win32": @@ -801,5 +1138,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/types.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/types.py index 37d55a3..7fd0031 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/types.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,119 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None + + +@dataclass +class Subscription: + """Information about a subscription. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + :param subscription_discovery: combination of settings that can be manually + chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games + for subscription when user doesn't own it, then USER_ENABLED should not be used. + If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used. + + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \ + SubscriptionDiscovery.USER_ENABLED + + def __post_init__(self): + assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED, + SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED] + + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game will be removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/http.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/http.py index 615daa0..a5bc76a 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/http.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/http.py @@ -1,12 +1,11 @@ """ -This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0. +This module standardizes http traffic and the error handling for further communication with the GOG Galaxy 2.0. It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions. -Examplary simple web service could looks like: +Exemplary simple web service could looks like: .. code-block:: python - import logging from galaxy.http import create_client_session, handle_exception class BackendClient: @@ -44,6 +43,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -70,7 +71,7 @@ async def request(self, method, url, *args, **kwargs): def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: """ - Creates TCP connector with resonable defaults. + Creates TCP connector with reasonable defaults. For details about available parameters refer to `aiohttp.TCPConnector `_ """ @@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: """ - Creates client session with resonable defaults. + Creates client session with reasonable defaults. For details about available parameters refer to `aiohttp.ClientSession `_ - Examplary customization: + Exemplary customization: .. code-block:: python @@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -120,25 +123,25 @@ def handle_exception(): raise BackendNotAvailable() except aiohttp.ClientConnectionError: raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() + except aiohttp.ContentTypeError as error: + raise UnknownBackendResponse(error.message) except aiohttp.ClientResponseError as error: if error.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() + raise AuthenticationRequired(error.message) if error.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() + raise AccessDenied(error.message) if error.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() + raise BackendNotAvailable(error.message) if error.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() + raise TooManyRequests(error.message) if error.status >= 500: - raise BackendError() + raise BackendError(error.message) if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) - raise UnknownError() - except aiohttp.ClientError: - logging.exception("Caught exception while performing request") - raise UnknownError() + raise UnknownError(error.message) + except aiohttp.ClientError as e: + logger.exception("Caught exception while performing request") + raise UnknownError(repr(e)) diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/proc_tools.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/proc_tools.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/reader.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/reader.py index 551f803..732e658 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/reader.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/registry_monitor.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/registry_monitor.py index 02b1a5a..1396535 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/registry_monitor.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/registry_monitor.py @@ -1,5 +1,7 @@ -import platform -if platform.system().lower() == "windows": +import sys + + +if sys.platform == "win32": import logging import ctypes from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID @@ -76,11 +78,10 @@ def is_updated(self): if self._key is None: self._open_key() - if self._key is None: - return False + if self._key is not None: + self._set_key_update_notification() - self._set_key_update_notification() - return True + return False def _set_key_update_notification(self): filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/task_manager.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/task_manager.py index 1ff20e2..e7bb517 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/task_manager.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task @@ -47,6 +51,3 @@ async def wait(self): if not tasks: return await asyncio.gather(*tasks, return_exceptions=True) - - - diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/__init__.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/mock.py b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/mock.py +++ b/plugins/snes_bc831044-f772-4391-8c22-529f42cb9799/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error From 26ebab1e448c945880101d93bc3e87cfae3e2367 Mon Sep 17 00:00:00 2001 From: jshackles Date: Sun, 18 Apr 2021 15:09:50 -0700 Subject: [PATCH 36/37] ADD: compiled binary --- README.md | 2 +- RetroGOG.exe | Bin 0 -> 993792 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 RetroGOG.exe diff --git a/README.md b/README.md index 2976f95..ee1a8ec 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in 4. Navigate to *Settings*, click on *Saving*, go to the last option there *Save runtime log (aggregate)* and turn it on. #### Setting up RetroGOG: -1. Download the repository and build RetroGOG.exe from the src folder using Visual Studio. +1. Download [RetroGOG configuration wizard](RetroGOG.exe) 2. Run the application and follow the on-screen instructions. 3. (Re)start Galaxy 2.0 and connect the integration. diff --git a/RetroGOG.exe b/RetroGOG.exe new file mode 100644 index 0000000000000000000000000000000000000000..b0e7115c1403d386a1bad4b583869d0f892c5c99 GIT binary patch literal 993792 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 z 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%0DkOti*#}4Hh@fU8L{J*9*o54KGnCs^iS9 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 L+$@V1vI+bPr3Ga$ literal 0 HcmV?d00001 From b59bf01a6af121a37afe6f1a69db68364278f4ed Mon Sep 17 00:00:00 2001 From: jshackles <jshackles@gmail.com> Date: Sun, 18 Apr 2021 15:11:27 -0700 Subject: [PATCH 37/37] UPDATE: direct exe link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ee1a8ec..3998e2f 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Forked from Riku55's N64 Integration Plugin: https://github.com/Riku55/galaxy-in 4. Navigate to *Settings*, click on *Saving*, go to the last option there *Save runtime log (aggregate)* and turn it on. #### Setting up RetroGOG: -1. Download [RetroGOG configuration wizard](RetroGOG.exe) +1. Download [RetroGOG configuration wizard](https://github.com/jshackles/RetroGOG/raw/master/RetroGOG.exe) 2. Run the application and follow the on-screen instructions. 3. (Re)start Galaxy 2.0 and connect the integration.

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