mirror of
https://github.com/derrod/legendary.git
synced 2024-09-29 08:52:11 +13:00
Compare commits
247 commits
Author | SHA1 | Date | |
---|---|---|---|
|
3963382b3f | ||
|
49dcdf1a59 | ||
|
56a2314e40 | ||
|
4d63dcc188 | ||
|
f1f5cc07f6 | ||
|
08c64ebca1 | ||
|
09d280f476 | ||
|
9395eb94ab | ||
|
90e5f75af0 | ||
|
7fefdc4973 | ||
|
96e07ff453 | ||
|
ac6290627c | ||
|
691048d481 | ||
|
837c166187 | ||
|
1841da51f0 | ||
|
56d439ed2d | ||
|
2fdacb75d3 | ||
|
d2963db5b2 | ||
|
f1d815797f | ||
|
591039eaf3 | ||
|
9131f32c22 | ||
|
450784283d | ||
|
c56a81ab64 | ||
|
488d14c6e0 | ||
|
4c765325af | ||
|
c6e622f3ae | ||
|
013f7d4bde | ||
|
03b21f49de | ||
|
bd2e7ca0cd | ||
|
20b121bdb9 | ||
|
b759d9dbb1 | ||
|
51377e8548 | ||
|
07a16f7b84 | ||
|
c69301212c | ||
|
865dd51e2b | ||
|
6536473063 | ||
|
6d7909c311 | ||
|
0e35b70941 | ||
|
e0428b497e | ||
|
6500ea73af | ||
|
96b155800a | ||
|
4145381b93 | ||
|
e26b9e60ff | ||
|
bdd53fb8f8 | ||
|
bbb19d6cb6 | ||
|
175168adcb | ||
|
8b2809779f | ||
|
4bed49e7e1 | ||
|
f97d799e87 | ||
|
09d39b3fe3 | ||
|
a70ac2d1f9 | ||
|
362287543b | ||
|
ae05b4c1e5 | ||
|
6b8273f983 | ||
|
00f025dcc9 | ||
|
87b01b77d8 | ||
|
f19a1ba69d | ||
|
c8a6e68bf4 | ||
|
2ed9557b2c | ||
|
da23690510 | ||
|
c3eb6b4fe6 | ||
|
032b7fc64f | ||
|
29086276ee | ||
|
4c99bf8987 | ||
|
6709e8aa4f | ||
|
4722e38081 | ||
|
2ffd183554 | ||
|
d59e973816 | ||
|
f80ceb50f3 | ||
|
cf22de2bcf | ||
|
ddb7e1c3ca | ||
|
36e6e5f08a | ||
|
0e23b8e4f0 | ||
|
85f6bd3220 | ||
|
9e5fbaf21a | ||
|
ecb405172b | ||
|
c053860f25 | ||
|
3ab31561bf | ||
|
66ef0f3d5e | ||
|
c0d67882bb | ||
|
338fef2fac | ||
|
075f446add | ||
|
0eec8472a4 | ||
|
abd3a9d496 | ||
|
53e2accbb0 | ||
|
e111ae56fc | ||
|
88d30322b5 | ||
|
b136748168 | ||
|
5a20f12461 | ||
|
f26c8ab0a1 | ||
|
0d23775337 | ||
|
d8af06c936 | ||
|
a73d0694f6 | ||
|
f9a2dae282 | ||
|
7a617d35f3 | ||
|
e5ec8e25b3 | ||
|
dcfdfbc520 | ||
|
83072d0b39 | ||
|
410c840aa4 | ||
|
9e145278d5 | ||
|
594e60e850 | ||
|
496bda3345 | ||
|
fc73c1d4bf | ||
|
f902963b1a | ||
|
791fb5da11 | ||
|
46bda313d6 | ||
|
06b18fe94a | ||
|
40748a91ba | ||
|
e52223c3ce | ||
|
a3bc07e15a | ||
|
b7f4a9f45a | ||
|
60a504edde | ||
|
2b71b50d5c | ||
|
823d672c2c | ||
|
a12238e4ef | ||
|
2ef5401dbb | ||
|
1e97a4d791 | ||
|
ec91f69adc | ||
|
3d1042e27e | ||
|
d7360eef3e | ||
|
ca005f6274 | ||
|
cffb10188a | ||
|
f20ae123a3 | ||
|
0f0b430a3c | ||
|
7ac9ec7b5f | ||
|
b7ad4daeb2 | ||
|
6ab354b20e | ||
|
869c749908 | ||
|
3793601de3 | ||
|
858d2f98e6 | ||
|
158b28eaff | ||
|
778ecacbd3 | ||
|
180692195f | ||
|
3bc819e567 | ||
|
742d3a3b05 | ||
|
cf95da395c | ||
|
66a30d6b2a | ||
|
e6da49d0bf | ||
|
f21ecf1eda | ||
|
f0ca8e6a9b | ||
|
a25de242d9 | ||
|
4ab0c99a0f | ||
|
024c03eb55 | ||
|
49cc8db22f | ||
|
c86cb40c10 | ||
|
be4c1b1cda | ||
|
1c6e83e9f8 | ||
|
a48bad9999 | ||
|
710f5d07d7 | ||
|
8d28945e8b | ||
|
ed1cbfc87e | ||
|
f7f13ed749 | ||
|
ce68ae87bf | ||
|
58bd76c39e | ||
|
cf8bccc569 | ||
|
6c3f409c49 | ||
|
8f2d42892b | ||
|
df1c3e6a3c | ||
|
48baba6adc | ||
|
557724339d | ||
|
b30de01cc7 | ||
|
586aeaf6de | ||
|
9bcfb15cf8 | ||
|
ba1e05af53 | ||
|
976b7cebf0 | ||
|
cc5c7a90b8 | ||
|
4bccd460ad | ||
|
ac5af04980 | ||
|
de3f3f93af | ||
|
840210040f | ||
|
005089ee9b | ||
|
bec119bc03 | ||
|
ecb04324d5 | ||
|
cea5f42425 | ||
|
9a3652086b | ||
|
d3ea2c6cfd | ||
|
cc44149e68 | ||
|
e44998b786 | ||
|
8e4bb8d3dd | ||
|
202f07973a | ||
|
05aac59836 | ||
|
edadf1c780 | ||
|
0a63b8b007 | ||
|
6a408e8404 | ||
|
8a9ca14391 | ||
|
4a4e1397d4 | ||
|
0298a53315 | ||
|
ecb230511f | ||
|
d15f05fc60 | ||
|
08267025b4 | ||
|
9469d3cb6f | ||
|
2e6335bf09 | ||
|
688910bf91 | ||
|
e771ccdf19 | ||
|
a4c6dee7ef | ||
|
d70f0daa22 | ||
|
cd74af8832 | ||
|
0f481e1f31 | ||
|
72215875ee | ||
|
3fed7d2614 | ||
|
013792f7b9 | ||
|
8512a9a7a1 | ||
|
af08f5d11b | ||
|
dfaccba2cb | ||
|
fc66f9f372 | ||
|
2474c43b7b | ||
|
300110e2bc | ||
|
b8e5dac0d6 | ||
|
3cba1c8510 | ||
|
03ef95923d | ||
|
dd099c0afd | ||
|
99c97032b4 | ||
|
2adc0b1a3e | ||
|
6fb6bb14a4 | ||
|
0d491aed90 | ||
|
a0da79bc2c | ||
|
f0f4b545f5 | ||
|
3d877185b0 | ||
|
b5a2fba896 | ||
|
33b89f5e9a | ||
|
75f2da576b | ||
|
d2a6f16060 | ||
|
0e4ab85b2f | ||
|
bc1c27b8d2 | ||
|
e5ba44ecfa | ||
|
b5120fa99d | ||
|
4a743dc1ca | ||
|
c7030c480e | ||
|
cb69d7c9d7 | ||
|
8d71df0cc4 | ||
|
efaf25b9d9 | ||
|
21d62dcd76 | ||
|
b6cb31df8b | ||
|
1fd8acdee4 | ||
|
599e4766b2 | ||
|
e60c3f7aa7 | ||
|
a4c1f0e670 | ||
|
d941b9d61e | ||
|
6b91c5779b | ||
|
fbb4acbc88 | ||
|
ed0ac1e0b2 | ||
|
3c831da310 | ||
|
335619ff79 | ||
|
363ac15faa | ||
|
d61946d15d | ||
|
352d3d2d0d | ||
|
0e72950382 |
30 changed files with 3017 additions and 800 deletions
55
.github/workflows/python.yml
vendored
55
.github/workflows/python.yml
vendored
|
@ -11,26 +11,23 @@ jobs:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: ['ubuntu-20.04', 'windows-latest', 'macos-11']
|
os: ['ubuntu-24.04', 'windows-latest', 'macos-13']
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 3
|
max-parallel: 3
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.9'
|
python-version: '3.9'
|
||||||
|
|
||||||
- name: Python components
|
|
||||||
run: pip3 install --upgrade
|
|
||||||
setuptools
|
|
||||||
wheel
|
|
||||||
|
|
||||||
- name: Legendary dependencies and build tools
|
- name: Legendary dependencies and build tools
|
||||||
run: pip3 install --upgrade
|
run: pip3 install --upgrade
|
||||||
|
setuptools
|
||||||
pyinstaller
|
pyinstaller
|
||||||
requests
|
requests
|
||||||
|
filelock
|
||||||
|
|
||||||
- name: Optional dependencies (WebView)
|
- name: Optional dependencies (WebView)
|
||||||
run: pip3 install --upgrade pywebview
|
run: pip3 install --upgrade pywebview
|
||||||
|
@ -47,43 +44,37 @@ jobs:
|
||||||
--onefile
|
--onefile
|
||||||
--name legendary
|
--name legendary
|
||||||
${{ steps.strip.outputs.option }}
|
${{ steps.strip.outputs.option }}
|
||||||
|
-i ../assets/windows_icon.ico
|
||||||
cli.py
|
cli.py
|
||||||
env:
|
env:
|
||||||
PYTHONOPTIMIZE: 1
|
PYTHONOPTIMIZE: 1
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ runner.os }}-package
|
name: ${{ runner.os }}-package
|
||||||
path: legendary/dist/*
|
path: legendary/dist/*
|
||||||
|
|
||||||
deb:
|
deb:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ubuntu-22.04
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: ['ubuntu-20.04']
|
|
||||||
|
|
||||||
fail-fast: true
|
|
||||||
max-parallel: 3
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Dependencies
|
- name: Dependencies
|
||||||
run: sudo apt install
|
run: |
|
||||||
python3-all
|
sudo apt install ruby
|
||||||
python3-stdeb
|
sudo gem install fpm
|
||||||
dh-python
|
|
||||||
python3-requests
|
|
||||||
python3-setuptools
|
|
||||||
python3-wheel
|
|
||||||
# pywebview is too outdated on 20.04, re-enable this on 22.04
|
|
||||||
# python3-webview
|
|
||||||
# python3-gi
|
|
||||||
# python3-gi-cairo
|
|
||||||
# gir1.2-gtk-3.0
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: python3 setup.py --command-packages=stdeb.command bdist_deb
|
run: fpm
|
||||||
|
--input-type python
|
||||||
|
--output-type deb
|
||||||
|
--python-package-name-prefix python3
|
||||||
|
--deb-suggests python3-webview
|
||||||
|
--maintainer "Rodney <rodney@rodney.io>"
|
||||||
|
--category python
|
||||||
|
--depends "python3 >= 3.9"
|
||||||
|
setup.py
|
||||||
|
|
||||||
- name: Os version
|
- name: Os version
|
||||||
id: os_version
|
id: os_version
|
||||||
|
@ -91,7 +82,7 @@ jobs:
|
||||||
source /etc/os-release
|
source /etc/os-release
|
||||||
echo ::set-output name=version::$NAME-$VERSION_ID
|
echo ::set-output name=version::$NAME-$VERSION_ID
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.os_version.outputs.version }}-deb-package
|
name: ${{ steps.os_version.outputs.version }}-deb-package
|
||||||
path: deb_dist/*.deb
|
path: ./*.deb
|
||||||
|
|
524
README.md
524
README.md
|
@ -9,7 +9,10 @@ Its name as a tongue-in-cheek play on tiers of [item rarity in many MMORPGs](htt
|
||||||
|
|
||||||
Please read the the [config file](#config-file) and [cli usage](#usage) sections before creating an issue to avoid invalid reports.
|
Please read the the [config file](#config-file) and [cli usage](#usage) sections before creating an issue to avoid invalid reports.
|
||||||
|
|
||||||
If you run into any issues [talk to us on Discord](https://legendary.gl/discord) or [create an issue on GitHub](https://github.com/derrod/legendary/issues/new/choose) so we can fix it!
|
If you run into any issues [ask for help on our Discord](https://legendary.gl/discord) or [create an issue on GitHub](https://github.com/derrod/legendary/issues/new/choose) so we can fix it!
|
||||||
|
|
||||||
|
Finally, if you wish to support the project, please consider [buying me a coffee on Ko-Fi](https://ko-fi.com/derrod).
|
||||||
|
Alternatively, if you've been considering picking up a copy of CrossOver you can use my [affiliate link](https://www.codeweavers.com/?ad=892) and discount code `LEGENDARY15` in their store.
|
||||||
|
|
||||||
**Note:** Legendary is currently a CLI (command-line interface) application without a graphical user interface,
|
**Note:** Legendary is currently a CLI (command-line interface) application without a graphical user interface,
|
||||||
it has to be run from a terminal (e.g. PowerShell)
|
it has to be run from a terminal (e.g. PowerShell)
|
||||||
|
@ -29,14 +32,20 @@ it has to be run from a terminal (e.g. PowerShell)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Linux, Windows, or macOS (64-bit)
|
- Linux, Windows (8.1+), or macOS (12.0+)
|
||||||
+ macOS support is in an early stage, and only tested on 12.0+
|
+ 32-bit operating systems are not supported
|
||||||
- python 3.8+ (64-bit)
|
- python 3.9+ (64-bit)
|
||||||
- PyPI packages: `requests`, optionally `setuptools` and `wheel` for setup/building
|
+ (Windows) `pythonnet` is not yet compatible with 3.10+, use 3.9 if you plan to install `pywebview`
|
||||||
|
- PyPI packages:
|
||||||
|
+ `requests`
|
||||||
|
+ (optional) `pywebview` for webview-based login
|
||||||
|
+ (optional) `setuptools` and `wheel` for setup/building
|
||||||
|
|
||||||
|
**Note:** Running Windows applications on Linux or macOS requires [Wine](https://www.winehq.org/).
|
||||||
|
|
||||||
## How to run/install
|
## How to run/install
|
||||||
|
|
||||||
### Package Manager
|
### Package Manager (Linux)
|
||||||
|
|
||||||
Several distros already have packages available, check out the [Available Linux Packages](https://github.com/derrod/legendary/wiki/Available-Linux-Packages) wiki page for details.
|
Several distros already have packages available, check out the [Available Linux Packages](https://github.com/derrod/legendary/wiki/Available-Linux-Packages) wiki page for details.
|
||||||
|
|
||||||
|
@ -50,7 +59,7 @@ but more will be available in the future.
|
||||||
Note that since packages are maintained by third parties it may take a bit for them to be updated to the latest version.
|
Note that since packages are maintained by third parties it may take a bit for them to be updated to the latest version.
|
||||||
If you always want to have the latest features and fixes available then using the PyPI distribution is recommended.
|
If you always want to have the latest features and fixes available then using the PyPI distribution is recommended.
|
||||||
|
|
||||||
### Standalone
|
### Prebuilt Standalone Binary (Windows, macOS, and Linux)
|
||||||
|
|
||||||
Download the `legendary` or `legendary.exe` binary from [the latest release](https://github.com/derrod/legendary/releases/latest)
|
Download the `legendary` or `legendary.exe` binary from [the latest release](https://github.com/derrod/legendary/releases/latest)
|
||||||
and move it to somewhere in your `$PATH`/`%PATH%`. Don't forget to `chmod +x` it on Linux/macOS.
|
and move it to somewhere in your `$PATH`/`%PATH%`. Don't forget to `chmod +x` it on Linux/macOS.
|
||||||
|
@ -58,13 +67,13 @@ and move it to somewhere in your `$PATH`/`%PATH%`. Don't forget to `chmod +x` it
|
||||||
The Windows .exe and Linux/macOS executable were created with PyInstaller and will run standalone even without python being installed.
|
The Windows .exe and Linux/macOS executable were created with PyInstaller and will run standalone even without python being installed.
|
||||||
Note that on Linux glibc >= 2.25 is required, so older distributions such as Ubuntu 16.04 or Debian stretch will not work.
|
Note that on Linux glibc >= 2.25 is required, so older distributions such as Ubuntu 16.04 or Debian stretch will not work.
|
||||||
|
|
||||||
### Python package
|
### Python Package (any)
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
To prevent problems with permissions during installation, please upgrade your `pip` by running `python -m pip install -U pip --user`.
|
To prevent problems with permissions during installation, please upgrade your `pip` by running `python -m pip install -U pip --user`.
|
||||||
|
|
||||||
> **Tip:** You may need to replace `python` in the above command with `python3.8` on Linux/macOS, or `py -3.8` on Windows.
|
> **Tip:** You may need to replace `python` in the above command with `python3` on Linux/macOS, or `py -3` on Windows.
|
||||||
|
|
||||||
#### Installation from PyPI (recommended)
|
#### Installation from PyPI (recommended)
|
||||||
|
|
||||||
|
@ -92,7 +101,7 @@ but may require manually installing dependencies needed to build `PyGObject`.
|
||||||
|
|
||||||
#### Manually from the repo
|
#### Manually from the repo
|
||||||
|
|
||||||
- Install python3.8, setuptools, wheel, and requests
|
- Install python3.9, setuptools, wheel, and requests
|
||||||
- Clone the git repository and cd into it
|
- Clone the git repository and cd into it
|
||||||
- Run `pip install .`
|
- Run `pip install .`
|
||||||
|
|
||||||
|
@ -114,7 +123,7 @@ echo 'export PATH=$PATH:~/.local/bin' >> ~/.profile && source ~/.profile
|
||||||
|
|
||||||
### Directly from the repo (for dev/testing)
|
### Directly from the repo (for dev/testing)
|
||||||
|
|
||||||
- Install python3.8 and requests (optionally in a venv)
|
- Install python3.9 and requests (optionally in a venv)
|
||||||
- cd into the repository
|
- cd into the repository
|
||||||
- Run `pip install -e .`
|
- Run `pip install -e .`
|
||||||
|
|
||||||
|
@ -131,14 +140,14 @@ legendary auth
|
||||||
When using the prebuilt Windows executables of version 0.20.14 or higher this should open a new window with the Epic Login.
|
When using the prebuilt Windows executables of version 0.20.14 or higher this should open a new window with the Epic Login.
|
||||||
|
|
||||||
Otherwise, authentication is a little finicky since we have to go through the Epic website and manually copy a code.
|
Otherwise, authentication is a little finicky since we have to go through the Epic website and manually copy a code.
|
||||||
The login page should open in your browser and after logging in you should be presented with a JSON response that contains a code ("sid"), just copy the code into the terminal and hit enter.
|
The login page should open in your browser and after logging in you should be presented with a JSON response that contains a code ("authorizationCode"), just copy the code into the terminal and hit enter.
|
||||||
|
|
||||||
Alternatively you can use the `--import` flag to import the authentication from the Epic Games Launcher (manually specifying the used WINE prefix may be required on Linux).
|
Alternatively you can use the `--import` flag to import the authentication from the Epic Games Launcher (manually specifying the used WINE prefix may be required on Linux).
|
||||||
Note that this will log you out of the Epic Launcher.
|
Note that this will log you out of the Epic Launcher.
|
||||||
|
|
||||||
Listing your games
|
Listing your games
|
||||||
````
|
````
|
||||||
legendary list-games
|
legendary list
|
||||||
````
|
````
|
||||||
This will fetch a list of games available on your account, the first time may take a while depending on how many games you have.
|
This will fetch a list of games available on your account, the first time may take a while depending on how many games you have.
|
||||||
|
|
||||||
|
@ -155,13 +164,15 @@ legendary list-installed --check-updates
|
||||||
|
|
||||||
Launch (run) a game with online authentication
|
Launch (run) a game with online authentication
|
||||||
````
|
````
|
||||||
legendary launch Anemone
|
legendary launch "world of goo"
|
||||||
````
|
````
|
||||||
**Tip:** most games will run fine offline (`--offline`), and thus won't require launching through legendary for online authentication. You can run `legendary launch <App Name> --offline --dry-run` to get a command line that will launch the game with all parameters that would be used by the Epic Launcher. These can then be entered into any other game launcher (e.g. Lutris/Steam) if the game requires them.
|
**Tip:** most games will run fine offline (`--offline`), and thus won't require launching through legendary for online authentication.
|
||||||
|
You can run `legendary launch <App Name> --offline --dry-run` to get a command line that will launch the game with all parameters that would be used by the Epic Launcher.
|
||||||
|
These can then be entered into any other game launcher (e.g. Lutris/Steam) if the game requires them.
|
||||||
|
|
||||||
Importing a previously installed game
|
Importing a previously installed game
|
||||||
````
|
````
|
||||||
legendary import-game Anemone /mnt/games/Epic/WorldOfGoo
|
legendary import Anemone /mnt/games/Epic/WorldOfGoo
|
||||||
````
|
````
|
||||||
**Note:** Importing will require a full verification so Legendary can correctly update the game later.
|
**Note:** Importing will require a full verification so Legendary can correctly update the game later.
|
||||||
**Note 2:** In order to use an alias here you may have to put it into quotes if if contains more than one word, e.g. `legendary import-game "world of goo" /mnt/games/Epic/WorldOfGoo`.
|
**Note 2:** In order to use an alias here you may have to put it into quotes if if contains more than one word, e.g. `legendary import-game "world of goo" /mnt/games/Epic/WorldOfGoo`.
|
||||||
|
@ -180,47 +191,75 @@ legendary -y egl-sync
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
````
|
````
|
||||||
usage: legendary [-h] [-v] [-y] [-V] [-c <path/name>] [-J]
|
usage: legendary [-h] [-H] [-v] [-y] [-V] [-J] [-A <seconds>] <command> ...
|
||||||
{auth,install,download,update,repair,uninstall,launch,list-games,list-installed,list-files,list-saves,download-saves,clean-saves,sync-saves,verify-game,import-game,egl-sync,status,info,alias,cleanup,activate}
|
|
||||||
...
|
|
||||||
|
|
||||||
Legendary v0.X.X - "Codename"
|
Legendary v0.X.X - "Codename"
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
|
-H, --full-help Show full help (including individual command help)
|
||||||
-v, --debug Set loglevel to debug
|
-v, --debug Set loglevel to debug
|
||||||
-y, --yes Default to yes for all prompts
|
-y, --yes Default to yes for all prompts
|
||||||
-V, --version Print version and exit
|
-V, --version Print version and exit
|
||||||
-c <path/name>, --config-file <path/name>
|
|
||||||
Specify custom config file or name for the config file
|
|
||||||
in the default directory.
|
|
||||||
-J, --pretty-json Pretty-print JSON
|
-J, --pretty-json Pretty-print JSON
|
||||||
|
-A <seconds>, --api-timeout <seconds>
|
||||||
|
API HTTP request timeout (default: 10 seconds)
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
{auth,install,download,update,repair,uninstall,launch,list-games,list-installed,list-files,list-saves,download-saves,clean-saves,sync-saves,verify-game,import-game,egl-sync,status,info,alias,cleanup,activate}
|
<command>
|
||||||
auth Authenticate with EPIC
|
|
||||||
install (download,update,repair)
|
|
||||||
Download a game
|
|
||||||
uninstall Uninstall (delete) a game
|
|
||||||
launch Launch a game
|
|
||||||
list-games List available (installable) games
|
|
||||||
list-installed List installed games
|
|
||||||
list-files List files in manifest
|
|
||||||
list-saves List available cloud saves
|
|
||||||
download-saves Download all cloud saves
|
|
||||||
clean-saves Clean cloud saves
|
|
||||||
sync-saves Sync cloud saves
|
|
||||||
verify-game Verify a game's local files
|
|
||||||
import-game Import an already installed game
|
|
||||||
egl-sync Setup or run Epic Games Launcher sync
|
|
||||||
status Show legendary status information
|
|
||||||
info Prints info about specified app name or manifest
|
|
||||||
alias Manage aliases
|
|
||||||
cleanup Remove old temporary, metadata, and manifest files
|
|
||||||
activate Activate games on third party launchers
|
activate Activate games on third party launchers
|
||||||
|
alias Manage aliases
|
||||||
|
auth Authenticate with the Epic Games Store
|
||||||
|
clean-saves Clean cloud saves
|
||||||
|
cleanup Remove old temporary, metadata, and manifest files
|
||||||
|
crossover Setup CrossOver for launching games (macOS only)
|
||||||
|
download-saves Download all cloud saves
|
||||||
|
egl-sync Setup or run Epic Games Launcher sync
|
||||||
|
eos-overlay Manage EOS Overlay install
|
||||||
|
import Import an already installed game
|
||||||
|
info Prints info about specified app name or manifest
|
||||||
|
install (download, update, repair)
|
||||||
|
Install/download/update/repair a game
|
||||||
|
launch Launch a game
|
||||||
|
list List available (installable) games
|
||||||
|
list-files List files in manifest
|
||||||
|
list-installed List installed games
|
||||||
|
list-saves List available cloud saves
|
||||||
|
move Move specified app name to a new location
|
||||||
|
status Show legendary status information
|
||||||
|
sync-saves Sync cloud saves
|
||||||
|
uninstall Uninstall (delete) a game
|
||||||
|
verify Verify a game's local files
|
||||||
|
|
||||||
Individual command help:
|
Individual command help:
|
||||||
|
|
||||||
|
Command: activate
|
||||||
|
usage: legendary activate [-h] (-U | -O)
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-U, --uplay Activate Uplay/Ubisoft Connect titles on your Ubisoft account
|
||||||
|
(Uplay install not required)
|
||||||
|
-O, --origin Activate Origin/EA App managed titles on your EA account
|
||||||
|
(requires Origin to be installed)
|
||||||
|
|
||||||
|
|
||||||
|
Command: alias
|
||||||
|
usage: legendary alias [-h]
|
||||||
|
<add|rename|remove|list> [<App name/Old alias>]
|
||||||
|
[<New alias>]
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
<add|rename|remove|list>
|
||||||
|
Action: Add, rename, remove, or list alias(es)
|
||||||
|
<App name/Old alias> App name when using "add" or "list" action, existing
|
||||||
|
alias when using "rename" or "remove" action
|
||||||
|
<New alias> New alias when using "add" action
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
|
||||||
|
|
||||||
Command: auth
|
Command: auth
|
||||||
usage: legendary auth [-h] [--import] [--code <exchange code>]
|
usage: legendary auth [-h] [--import] [--code <exchange code>]
|
||||||
[--sid <session id>] [--delete] [--disable-webview]
|
[--sid <session id>] [--delete] [--disable-webview]
|
||||||
|
@ -229,15 +268,153 @@ optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
--import Import Epic Games Launcher authentication data (logs
|
--import Import Epic Games Launcher authentication data (logs
|
||||||
out of EGL)
|
out of EGL)
|
||||||
--code <exchange code>
|
--code <authorization code>
|
||||||
Use specified exchange code instead of interactive
|
Use specified authorization code instead of interactive authentication
|
||||||
authentication
|
--token <exchange token>
|
||||||
|
Use specified exchange token instead of interactive authentication
|
||||||
--sid <session id> Use specified session id instead of interactive
|
--sid <session id> Use specified session id instead of interactive
|
||||||
authentication
|
authentication
|
||||||
--delete Remove existing authentication (log out)
|
--delete Remove existing authentication (log out)
|
||||||
--disable-webview Do not use embedded browser for login
|
--disable-webview Do not use embedded browser for login
|
||||||
|
|
||||||
|
|
||||||
|
Command: clean-saves
|
||||||
|
usage: legendary clean-saves [-h] [--delete-incomplete] [<App Name>]
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
<App Name> Name of the app (optional)
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--delete-incomplete Delete incomplete save files
|
||||||
|
|
||||||
|
|
||||||
|
Command: cleanup
|
||||||
|
usage: legendary cleanup [-h] [--keep-manifests]
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--keep-manifests Do not delete old manifests
|
||||||
|
|
||||||
|
|
||||||
|
Command: crossover
|
||||||
|
usage: legendary crossover [-h] [--reset] [--download] [--ignore-version]
|
||||||
|
[--crossover-app <path to .app>]
|
||||||
|
[--crossover-bottle <bottle name>]
|
||||||
|
[<App Name>]
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
<App Name> App name to configure, will configure defaults if
|
||||||
|
ommited
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--reset Reset default/app-specific crossover configuration
|
||||||
|
--download Automatically download and set up a preconfigured
|
||||||
|
bottle (experimental)
|
||||||
|
--ignore-version Disable version check for available bottles when using
|
||||||
|
--download
|
||||||
|
--crossover-app <path to .app>
|
||||||
|
Specify app to skip interactive selection
|
||||||
|
--crossover-bottle <bottle name>
|
||||||
|
Specify bottle to skip interactive selection
|
||||||
|
|
||||||
|
|
||||||
|
Command: download-saves
|
||||||
|
usage: legendary download-saves [-h] [<App Name>]
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
<App Name> Name of the app (optional)
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
|
||||||
|
|
||||||
|
Command: egl-sync
|
||||||
|
usage: legendary egl-sync [-h] [--egl-manifest-path EGL_MANIFEST_PATH]
|
||||||
|
[--egl-wine-prefix EGL_WINE_PREFIX] [--enable-sync]
|
||||||
|
[--disable-sync] [--one-shot] [--import-only]
|
||||||
|
[--export-only] [--migrate] [--unlink]
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--egl-manifest-path EGL_MANIFEST_PATH
|
||||||
|
Path to the Epic Games Launcher's Manifests folder,
|
||||||
|
should point to
|
||||||
|
/ProgramData/Epic/EpicGamesLauncher/Data/Manifests
|
||||||
|
--egl-wine-prefix EGL_WINE_PREFIX
|
||||||
|
Path to the WINE prefix the Epic Games Launcher is
|
||||||
|
installed in
|
||||||
|
--enable-sync Enable automatic EGL <-> Legendary sync
|
||||||
|
--disable-sync Disable automatic sync and exit
|
||||||
|
--one-shot Sync once, do not ask to setup automatic sync
|
||||||
|
--import-only Only import games from EGL (no export)
|
||||||
|
--export-only Only export games to EGL (no import)
|
||||||
|
--migrate Import games into legendary, then remove them from EGL
|
||||||
|
(implies --import-only --one-shot --unlink)
|
||||||
|
--unlink Disable sync and remove EGL metadata from installed
|
||||||
|
games
|
||||||
|
|
||||||
|
|
||||||
|
Command: eos-overlay
|
||||||
|
usage: legendary eos-overlay [-h] [--path PATH] [--prefix PREFIX] [--app APP]
|
||||||
|
[--bottle BOTTLE]
|
||||||
|
<install|update|remove|enable|disable|info>
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
<install|update|remove|enable|disable|info>
|
||||||
|
Action: install, remove, enable, disable, or print
|
||||||
|
info about the overlay
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--path PATH Path to the EOS overlay folder to be enabled/installed
|
||||||
|
to.
|
||||||
|
--prefix PREFIX WINE prefix to install the overlay in
|
||||||
|
--app APP Use this app's wine prefix (if configured in config)
|
||||||
|
--bottle BOTTLE WINE prefix to install the overlay in
|
||||||
|
|
||||||
|
|
||||||
|
Command: import
|
||||||
|
usage: legendary import [-h] [--disable-check] [--with-dlcs] [--skip-dlcs]
|
||||||
|
[--platform <Platform>]
|
||||||
|
<App Name> <Installation directory>
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
<App Name> Name of the app
|
||||||
|
<Installation directory>
|
||||||
|
Path where the game is installed
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--disable-check Disables completeness check of the to-be-imported game
|
||||||
|
installation (useful if the imported game is a much
|
||||||
|
older version or missing files)
|
||||||
|
--with-dlcs Automatically attempt to import all DLCs with the base
|
||||||
|
game
|
||||||
|
--skip-dlcs Do not ask about importing DLCs.
|
||||||
|
--platform <Platform>
|
||||||
|
Platform for import (default: Mac on macOS, otherwise
|
||||||
|
Windows)
|
||||||
|
|
||||||
|
|
||||||
|
Command: info
|
||||||
|
usage: legendary info [-h] [--offline] [--json] [--platform <Platform>]
|
||||||
|
<App Name/Manifest URI>
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
<App Name/Manifest URI>
|
||||||
|
App name or manifest path/URI
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--offline Only print info available offline
|
||||||
|
--json Output information in JSON format
|
||||||
|
--platform <Platform>
|
||||||
|
Platform to fetch info for (default: installed or Mac
|
||||||
|
on macOS, Windows otherwise)
|
||||||
|
|
||||||
|
|
||||||
Command: install
|
Command: install
|
||||||
usage: legendary install <App Name> [options]
|
usage: legendary install <App Name> [options]
|
||||||
|
|
||||||
|
@ -248,7 +425,7 @@ positional arguments:
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
--base-path <path> Path for game installations (defaults to ~/legendary)
|
--base-path <path> Path for game installations (defaults to ~/Games)
|
||||||
--game-folder <path> Folder for game installation (defaults to folder
|
--game-folder <path> Folder for game installation (defaults to folder
|
||||||
specified in metadata)
|
specified in metadata)
|
||||||
--max-shared-memory <size>
|
--max-shared-memory <size>
|
||||||
|
@ -310,17 +487,6 @@ optional arguments:
|
||||||
--skip-dlcs Do not ask about installing DLCs.
|
--skip-dlcs Do not ask about installing DLCs.
|
||||||
|
|
||||||
|
|
||||||
Command: uninstall
|
|
||||||
usage: legendary uninstall [-h] [--keep-files] <App Name>
|
|
||||||
|
|
||||||
positional arguments:
|
|
||||||
<App Name> Name of the app
|
|
||||||
|
|
||||||
optional arguments:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
--keep-files Keep files but remove game from Legendary database
|
|
||||||
|
|
||||||
|
|
||||||
Command: launch
|
Command: launch
|
||||||
usage: legendary launch <App Name> [options]
|
usage: legendary launch <App Name> [options]
|
||||||
|
|
||||||
|
@ -355,11 +521,18 @@ optional arguments:
|
||||||
--wine-prefix <wine pfx path>
|
--wine-prefix <wine pfx path>
|
||||||
Set WINE prefix to use
|
Set WINE prefix to use
|
||||||
--no-wine Do not run game with WINE (e.g. if a wrapper is used)
|
--no-wine Do not run game with WINE (e.g. if a wrapper is used)
|
||||||
|
--crossover Interactively configure CrossOver for this
|
||||||
|
application.
|
||||||
|
--crossover-app <path to .app>
|
||||||
|
Specify which App to use for CrossOver (e.g.
|
||||||
|
"/Applications/CrossOver.app")
|
||||||
|
--crossover-bottle <bottle name>
|
||||||
|
Specify which bottle to use for CrossOver
|
||||||
|
|
||||||
|
|
||||||
Command: list-games
|
Command: list
|
||||||
usage: legendary list-games [-h] [--platform <Platform>] [--include-ue] [-T]
|
usage: legendary list [-h] [--platform <Platform>] [--include-ue] [-T] [--csv]
|
||||||
[--csv] [--tsv] [--json] [--force-refresh]
|
[--tsv] [--json] [--force-refresh]
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
|
@ -377,19 +550,6 @@ optional arguments:
|
||||||
--force-refresh Force a refresh of all game metadata
|
--force-refresh Force a refresh of all game metadata
|
||||||
|
|
||||||
|
|
||||||
Command: list-installed
|
|
||||||
usage: legendary list-installed [-h] [--check-updates] [--csv] [--tsv]
|
|
||||||
[--json] [--show-dirs]
|
|
||||||
|
|
||||||
optional arguments:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
--check-updates Check for updates for installed games
|
|
||||||
--csv List games in CSV format
|
|
||||||
--tsv List games in TSV format
|
|
||||||
--json List games in JSON format
|
|
||||||
--show-dirs Print installation directory in output
|
|
||||||
|
|
||||||
|
|
||||||
Command: list-files
|
Command: list-files
|
||||||
usage: legendary list-files [-h] [--force-download] [--platform <Platform>]
|
usage: legendary list-files [-h] [--force-download] [--platform <Platform>]
|
||||||
[--manifest <uri>] [--csv] [--tsv] [--json]
|
[--manifest <uri>] [--csv] [--tsv] [--json]
|
||||||
|
@ -413,6 +573,19 @@ optional arguments:
|
||||||
--install-tag <tag> Show only files with specified install tag
|
--install-tag <tag> Show only files with specified install tag
|
||||||
|
|
||||||
|
|
||||||
|
Command: list-installed
|
||||||
|
usage: legendary list-installed [-h] [--check-updates] [--csv] [--tsv]
|
||||||
|
[--json] [--show-dirs]
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--check-updates Check for updates for installed games
|
||||||
|
--csv List games in CSV format
|
||||||
|
--tsv List games in TSV format
|
||||||
|
--json List games in JSON format
|
||||||
|
--show-dirs Print installation directory in output
|
||||||
|
|
||||||
|
|
||||||
Command: list-saves
|
Command: list-saves
|
||||||
usage: legendary list-saves [-h] [<App Name>]
|
usage: legendary list-saves [-h] [<App Name>]
|
||||||
|
|
||||||
|
@ -423,25 +596,26 @@ optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
|
|
||||||
|
|
||||||
Command: download-saves
|
Command: move
|
||||||
usage: legendary download-saves [-h] [<App Name>]
|
usage: legendary move [-h] [--skip-move] <App Name> <New Base Path>
|
||||||
|
|
||||||
positional arguments:
|
positional arguments:
|
||||||
<App Name> Name of the app (optional)
|
<App Name> Name of the app
|
||||||
|
<New Base Path> Directory to move game folder to
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--skip-move Only change legendary database, do not move files (e.g. if
|
||||||
|
already moved)
|
||||||
|
|
||||||
|
|
||||||
|
Command: status
|
||||||
|
usage: legendary status [-h] [--offline] [--json]
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
|
--offline Only print offline status information, do not login
|
||||||
|
--json Show status in JSON format
|
||||||
Command: clean-saves
|
|
||||||
usage: legendary clean-saves [-h] [--delete-incomplete] [<App Name>]
|
|
||||||
|
|
||||||
positional arguments:
|
|
||||||
<App Name> Name of the app (optional)
|
|
||||||
|
|
||||||
optional arguments:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
--delete-incomplete Delete incomplete save files
|
|
||||||
|
|
||||||
|
|
||||||
Command: sync-saves
|
Command: sync-saves
|
||||||
|
@ -464,139 +638,28 @@ optional arguments:
|
||||||
--disable-filters Disable save game file filtering
|
--disable-filters Disable save game file filtering
|
||||||
|
|
||||||
|
|
||||||
Command: verify-game
|
Command: uninstall
|
||||||
usage: legendary verify-game [-h] <App Name>
|
usage: legendary uninstall [-h] [--keep-files] <App Name>
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
<App Name> Name of the app
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--keep-files Keep files but remove game from Legendary database
|
||||||
|
|
||||||
|
|
||||||
|
Command: verify
|
||||||
|
usage: legendary verify [-h] <App Name>
|
||||||
|
|
||||||
positional arguments:
|
positional arguments:
|
||||||
<App Name> Name of the app
|
<App Name> Name of the app
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
|
|
||||||
|
|
||||||
Command: import-game
|
|
||||||
usage: legendary import-game [-h] [--disable-check] [--with-dlcs]
|
|
||||||
[--skip-dlcs] [--platform <Platform>]
|
|
||||||
<App Name> <Installation directory>
|
|
||||||
|
|
||||||
positional arguments:
|
|
||||||
<App Name> Name of the app
|
|
||||||
<Installation directory>
|
|
||||||
Path where the game is installed
|
|
||||||
|
|
||||||
optional arguments:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
--disable-check Disables completeness check of the to-be-imported game
|
|
||||||
installation (useful if the imported game is a much
|
|
||||||
older version or missing files)
|
|
||||||
--with-dlcs Automatically attempt to import all DLCs with the base
|
|
||||||
game
|
|
||||||
--skip-dlcs Do not ask about importing DLCs.
|
|
||||||
--platform <Platform>
|
|
||||||
Platform for import (default: Mac on macOS, otherwise
|
|
||||||
Windows)
|
|
||||||
|
|
||||||
|
|
||||||
Command: egl-sync
|
|
||||||
usage: legendary egl-sync [-h] [--egl-manifest-path EGL_MANIFEST_PATH]
|
|
||||||
[--egl-wine-prefix EGL_WINE_PREFIX] [--enable-sync]
|
|
||||||
[--disable-sync] [--one-shot] [--import-only]
|
|
||||||
[--export-only] [--unlink]
|
|
||||||
|
|
||||||
optional arguments:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
--egl-manifest-path EGL_MANIFEST_PATH
|
|
||||||
Path to the Epic Games Launcher's Manifests folder,
|
|
||||||
should point to
|
|
||||||
/ProgramData/Epic/EpicGamesLauncher/Data/Manifests
|
|
||||||
--egl-wine-prefix EGL_WINE_PREFIX
|
|
||||||
Path to the WINE prefix the Epic Games Launcher is
|
|
||||||
installed in
|
|
||||||
--enable-sync Enable automatic EGL <-> Legendary sync
|
|
||||||
--disable-sync Disable automatic sync and exit
|
|
||||||
--one-shot Sync once, do not ask to setup automatic sync
|
|
||||||
--import-only Only import games from EGL (no export)
|
|
||||||
--export-only Only export games to EGL (no import)
|
|
||||||
--unlink Disable sync and remove EGL metadata from installed
|
|
||||||
games
|
|
||||||
|
|
||||||
|
|
||||||
Command: status
|
|
||||||
usage: legendary status [-h] [--offline] [--json]
|
|
||||||
|
|
||||||
optional arguments:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
--offline Only print offline status information, do not login
|
|
||||||
--json Show status in JSON format
|
|
||||||
|
|
||||||
|
|
||||||
Command: info
|
|
||||||
usage: legendary info [-h] [--offline] [--json] [--platform <Platform>]
|
|
||||||
<App Name/Manifest URI>
|
|
||||||
|
|
||||||
positional arguments:
|
|
||||||
<App Name/Manifest URI>
|
|
||||||
App name or manifest path/URI
|
|
||||||
|
|
||||||
optional arguments:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
--offline Only print info available offline
|
|
||||||
--json Output information in JSON format
|
|
||||||
--platform <Platform>
|
|
||||||
Platform to fetch info for (default: installed or Mac
|
|
||||||
on macOS, Windows otherwise)
|
|
||||||
|
|
||||||
|
|
||||||
Command: alias
|
|
||||||
usage: legendary alias [-h]
|
|
||||||
<add|rename|remove|list> [App name/Old alias]
|
|
||||||
[New alias]
|
|
||||||
|
|
||||||
positional arguments:
|
|
||||||
<add|rename|remove|list>
|
|
||||||
Action: Add, rename, remove, or list alias(es)
|
|
||||||
App name/Old alias App name when using "add" or "list" action, existing
|
|
||||||
alias when using "rename" or "remove" action
|
|
||||||
New alias New alias when using "add" action
|
|
||||||
|
|
||||||
optional arguments:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
|
|
||||||
|
|
||||||
Command: cleanup
|
|
||||||
usage: legendary cleanup [-h] [--keep-manifests]
|
|
||||||
|
|
||||||
optional arguments:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
--keep-manifests Do not delete old manifests
|
|
||||||
|
|
||||||
|
|
||||||
Command: activate
|
|
||||||
usage: legendary activate [-h] (-U | -O)
|
|
||||||
|
|
||||||
optional arguments:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
-U, --uplay Activate Uplay/Ubisoft Connect titles on your Ubisoft account
|
|
||||||
(Uplay install not required)
|
|
||||||
-O, --origin Activate Origin/EA App managed titles on your EA account
|
|
||||||
(requires Origin to be installed)
|
|
||||||
````
|
````
|
||||||
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
Legendary supports overriding certain things via environment variables,
|
|
||||||
it also passes through any environment variables set before it is called.
|
|
||||||
|
|
||||||
Legendary specific environment variables:
|
|
||||||
+ `LGDRY_WINE_BINARY` - specifies wine binary
|
|
||||||
+ `LGDRY_WINE_PREFIX` - specified wine prefix
|
|
||||||
+ `LGDRY_NO_WINE` - disables wine
|
|
||||||
+ `LGDRY_WRAPPER` - specifies wrapper binary/command line
|
|
||||||
|
|
||||||
Note that the priority for settings that occur multiple times is:
|
|
||||||
command line > environment variables > config variables.
|
|
||||||
|
|
||||||
## Config file
|
## Config file
|
||||||
|
|
||||||
Legendary supports some options as well as game specific configuration in `~/.config/legendary/config.ini`:
|
Legendary supports some options as well as game specific configuration in `~/.config/legendary/config.ini`:
|
||||||
|
@ -604,8 +667,8 @@ Legendary supports some options as well as game specific configuration in `~/.co
|
||||||
[Legendary]
|
[Legendary]
|
||||||
log_level = debug
|
log_level = debug
|
||||||
; maximum shared memory (in MiB) to use for installation
|
; maximum shared memory (in MiB) to use for installation
|
||||||
max_memory = 1024
|
max_memory = 2048
|
||||||
; maximum number of worker processes when downloading (fewer workers will be slower, but also use fewer system resources)
|
; maximum number of worker processes when downloading (fewer workers will be slower, but also use less system resources)
|
||||||
max_workers = 8
|
max_workers = 8
|
||||||
; default install directory
|
; default install directory
|
||||||
install_dir = /mnt/tank/games
|
install_dir = /mnt/tank/games
|
||||||
|
@ -625,20 +688,33 @@ disable_update_check = false
|
||||||
disable_update_notice = false
|
disable_update_notice = false
|
||||||
; Disable automatically-generated aliases
|
; Disable automatically-generated aliases
|
||||||
disable_auto_aliasing = false
|
disable_auto_aliasing = false
|
||||||
; Default application platform to use
|
|
||||||
|
; macOS specific settings
|
||||||
|
; Default application platform to use (default: Mac on macOS, Windows elsewhere)
|
||||||
default_platform = Windows
|
default_platform = Windows
|
||||||
|
; Fallback to "Windows" platform if native version unavailable
|
||||||
|
install_platform_fallback = true
|
||||||
|
; (macOS) Disable automatic CrossOver use
|
||||||
|
disable_auto_crossover = false
|
||||||
|
; Default directory for native Mac applications (.app packages)
|
||||||
|
mac_install_dir = /User/legendary/Applications
|
||||||
|
|
||||||
[Legendary.aliases]
|
[Legendary.aliases]
|
||||||
; List of aliases for simpler CLI use
|
; List of aliases for simpler CLI use, in the format `<alias> = <app name>`
|
||||||
HITMAN 3 = Eider
|
HITMAN 3 = Eider
|
||||||
gtav = 9d2d0eb64d5c44529cece33fe2a46482
|
gtav = 9d2d0eb64d5c44529cece33fe2a46482
|
||||||
|
|
||||||
; default settings to use (currently limited to WINE executable)
|
; default settings to use for all apps (unless overridden in the app's config section)
|
||||||
|
; Note that only the settings listed below are supported.
|
||||||
[default]
|
[default]
|
||||||
; (linux) specify wine executable to use
|
; (all) wrapper to run the game with (e.g. "gamemode")
|
||||||
|
wrapper = gamemode
|
||||||
|
; (linux/macOS) Wine executable and prefix
|
||||||
wine_executable = wine
|
wine_executable = wine
|
||||||
; wine prefix (alternative to using environment variable)
|
|
||||||
wine_prefix = /home/user/.wine
|
wine_prefix = /home/user/.wine
|
||||||
|
; (macOS) CrossOver options
|
||||||
|
crossover_app = /Applications/CrossOver.app
|
||||||
|
crossover_bottle = Legendary
|
||||||
|
|
||||||
; default environment variables to set (overridden by game specific ones)
|
; default environment variables to set (overridden by game specific ones)
|
||||||
[default.env]
|
[default.env]
|
||||||
|
@ -652,9 +728,10 @@ offline = true
|
||||||
skip_update_check = true
|
skip_update_check = true
|
||||||
; start parameters to use (in addition to the required ones)
|
; start parameters to use (in addition to the required ones)
|
||||||
start_params = -windowed
|
start_params = -windowed
|
||||||
wine_executable = /path/to/wine64
|
|
||||||
; override language with two-letter language code
|
; override language with two-letter language code
|
||||||
language = fr
|
language = fr
|
||||||
|
; Override Wine version for this app
|
||||||
|
wine_executable = /path/to/wine64
|
||||||
|
|
||||||
[AppName.env]
|
[AppName.env]
|
||||||
; environment variables to set for this game (mostly useful on linux)
|
; environment variables to set for this game (mostly useful on linux)
|
||||||
|
@ -671,4 +748,13 @@ no_wine = true
|
||||||
override_exe = relative/path/to/file.exe
|
override_exe = relative/path/to/file.exe
|
||||||
; Disable selective downloading for this title
|
; Disable selective downloading for this title
|
||||||
disable_sdl = true
|
disable_sdl = true
|
||||||
|
|
||||||
|
[AppName3]
|
||||||
|
; Command to run before launching the gmae
|
||||||
|
pre_launch_command = /path/to/script.sh
|
||||||
|
; Whether or not to wait for command to finish running
|
||||||
|
pre_launch_wait = false
|
||||||
|
; (macOS) override crossover settings
|
||||||
|
crossover_app = /Applications/CrossOver Nightly.app
|
||||||
|
crossover_bottle = SomethingElse
|
||||||
````
|
````
|
||||||
|
|
BIN
assets/windows_icon.ico
Normal file
BIN
assets/windows_icon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
|
@ -1,4 +1,4 @@
|
||||||
"""Legendary!"""
|
"""Legendary!"""
|
||||||
|
|
||||||
__version__ = '0.20.22'
|
__version__ = '0.20.35'
|
||||||
__codename__ = 'Anticitizen One (hotfix #3)'
|
__codename__ = 'Lowlife'
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
# !/usr/bin/env python
|
# !/usr/bin/env python
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import requests.adapters
|
import requests.adapters
|
||||||
import logging
|
import logging
|
||||||
|
@ -13,7 +15,7 @@ from legendary.models.gql import *
|
||||||
|
|
||||||
class EPCAPI:
|
class EPCAPI:
|
||||||
_user_agent = 'UELauncher/11.0.1-14907503+++Portal+Release-Live Windows/10.0.19041.1.256.64bit'
|
_user_agent = 'UELauncher/11.0.1-14907503+++Portal+Release-Live Windows/10.0.19041.1.256.64bit'
|
||||||
_store_user_agent = 'EpicGamesLauncher/11.0.1-14907503+++Portal+Release-Live'
|
_store_user_agent = 'EpicGamesLauncher/14.0.8-22004686+++Portal+Release-Live'
|
||||||
# required for the oauth request
|
# required for the oauth request
|
||||||
_user_basic = '34a02cf8f4414e29b15921876da36f9a'
|
_user_basic = '34a02cf8f4414e29b15921876da36f9a'
|
||||||
_pw_basic = 'daafbccc737745039dffe53d94fc76cf'
|
_pw_basic = 'daafbccc737745039dffe53d94fc76cf'
|
||||||
|
@ -26,9 +28,13 @@ class EPCAPI:
|
||||||
_ecommerce_host = 'ecommerceintegration-public-service-ecomprod02.ol.epicgames.com'
|
_ecommerce_host = 'ecommerceintegration-public-service-ecomprod02.ol.epicgames.com'
|
||||||
_datastorage_host = 'datastorage-public-service-liveegs.live.use1a.on.epicgames.com'
|
_datastorage_host = 'datastorage-public-service-liveegs.live.use1a.on.epicgames.com'
|
||||||
_library_host = 'library-service.live.use1a.on.epicgames.com'
|
_library_host = 'library-service.live.use1a.on.epicgames.com'
|
||||||
_store_gql_host = 'store-launcher.epicgames.com'
|
# Using the actual store host with a user-agent newer than 14.0.8 leads to a CF verification page,
|
||||||
|
# but the dedicated graphql host works fine.
|
||||||
|
# _store_gql_host = 'launcher.store.epicgames.com'
|
||||||
|
_store_gql_host = 'graphql.epicgames.com'
|
||||||
|
_artifact_service_host = 'artifact-public-service-prod.beee.live.use1a.on.epicgames.com'
|
||||||
|
|
||||||
def __init__(self, lc='en', cc='US'):
|
def __init__(self, lc='en', cc='US', timeout=10.0):
|
||||||
self.log = logging.getLogger('EPCAPI')
|
self.log = logging.getLogger('EPCAPI')
|
||||||
|
|
||||||
self.session = requests.session()
|
self.session = requests.session()
|
||||||
|
@ -47,11 +53,18 @@ class EPCAPI:
|
||||||
self.language_code = lc
|
self.language_code = lc
|
||||||
self.country_code = cc
|
self.country_code = cc
|
||||||
|
|
||||||
|
self.request_timeout = timeout if timeout > 0 else None
|
||||||
|
|
||||||
|
def get_auth_url(self):
|
||||||
|
login_url = 'https://www.epicgames.com/id/login?redirectUrl='
|
||||||
|
redirect_url = f'https://www.epicgames.com/id/api/redirect?clientId={self._user_basic}&responseType=code'
|
||||||
|
return login_url + urllib.parse.quote(redirect_url)
|
||||||
|
|
||||||
def update_egs_params(self, egs_params):
|
def update_egs_params(self, egs_params):
|
||||||
# update user-agent
|
# update user-agent
|
||||||
if version := egs_params['version']:
|
if version := egs_params['version']:
|
||||||
self._user_agent = f'UELauncher/{version} Windows/10.0.19041.1.256.64bit'
|
self._user_agent = f'UELauncher/{version} Windows/10.0.19041.1.256.64bit'
|
||||||
self._user_agent = f'EpicGamesLauncher/{version}'
|
self._store_user_agent = f'EpicGamesLauncher/{version}'
|
||||||
self.session.headers['User-Agent'] = self._user_agent
|
self.session.headers['User-Agent'] = self._user_agent
|
||||||
self.unauth_session.headers['User-Agent'] = self._user_agent
|
self.unauth_session.headers['User-Agent'] = self._user_agent
|
||||||
# update label
|
# update label
|
||||||
|
@ -65,7 +78,8 @@ class EPCAPI:
|
||||||
|
|
||||||
def resume_session(self, session):
|
def resume_session(self, session):
|
||||||
self.session.headers['Authorization'] = f'bearer {session["access_token"]}'
|
self.session.headers['Authorization'] = f'bearer {session["access_token"]}'
|
||||||
r = self.session.get(f'https://{self._oauth_host}/account/api/oauth/verify')
|
r = self.session.get(f'https://{self._oauth_host}/account/api/oauth/verify',
|
||||||
|
timeout=self.request_timeout)
|
||||||
if r.status_code >= 500:
|
if r.status_code >= 500:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
@ -79,7 +93,8 @@ class EPCAPI:
|
||||||
self.user = session
|
self.user = session
|
||||||
return self.user
|
return self.user
|
||||||
|
|
||||||
def start_session(self, refresh_token: str = None, exchange_token: str = None) -> dict:
|
def start_session(self, refresh_token: str = None, exchange_token: str = None,
|
||||||
|
authorization_code: str = None, client_credentials: bool = False) -> dict:
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
params = dict(grant_type='refresh_token',
|
params = dict(grant_type='refresh_token',
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
|
@ -88,29 +103,49 @@ class EPCAPI:
|
||||||
params = dict(grant_type='exchange_code',
|
params = dict(grant_type='exchange_code',
|
||||||
exchange_code=exchange_token,
|
exchange_code=exchange_token,
|
||||||
token_type='eg1')
|
token_type='eg1')
|
||||||
|
elif authorization_code:
|
||||||
|
params = dict(grant_type='authorization_code',
|
||||||
|
code=authorization_code,
|
||||||
|
token_type='eg1')
|
||||||
|
elif client_credentials:
|
||||||
|
params = dict(grant_type='client_credentials',
|
||||||
|
token_type='eg1')
|
||||||
else:
|
else:
|
||||||
raise ValueError('At least one token type must be specified!')
|
raise ValueError('At least one token type must be specified!')
|
||||||
|
|
||||||
r = self.session.post(f'https://{self._oauth_host}/account/api/oauth/token',
|
r = self.session.post(f'https://{self._oauth_host}/account/api/oauth/token',
|
||||||
data=params, auth=self._oauth_basic)
|
data=params, auth=self._oauth_basic,
|
||||||
|
timeout=self.request_timeout)
|
||||||
# Only raise HTTP exceptions on server errors
|
# Only raise HTTP exceptions on server errors
|
||||||
if r.status_code >= 500:
|
if r.status_code >= 500:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
j = r.json()
|
j = r.json()
|
||||||
if 'error' in j:
|
if 'errorCode' in j:
|
||||||
self.log.warning(f'Login to EGS API failed with errorCode: {j["errorCode"]}')
|
if j['errorCode'] == 'errors.com.epicgames.oauth.corrective_action_required':
|
||||||
|
self.log.error(f'{j["errorMessage"]} ({j["correctiveAction"]}), '
|
||||||
|
f'open the following URL to take action: {j["continuationUrl"]}')
|
||||||
|
else:
|
||||||
|
self.log.error(f'Login to EGS API failed with errorCode: {j["errorCode"]}')
|
||||||
raise InvalidCredentialsError(j['errorCode'])
|
raise InvalidCredentialsError(j['errorCode'])
|
||||||
|
elif r.status_code >= 400:
|
||||||
|
self.log.error(f'EGS API responded with status {r.status_code} but no error in response: {j}')
|
||||||
|
raise InvalidCredentialsError('Unknown error')
|
||||||
|
|
||||||
self.user = j
|
self.session.headers['Authorization'] = f'bearer {j["access_token"]}'
|
||||||
self.session.headers['Authorization'] = f'bearer {self.user["access_token"]}'
|
# only set user info when using non-anonymous login
|
||||||
return self.user
|
if not client_credentials:
|
||||||
|
self.user = j
|
||||||
|
|
||||||
|
return j
|
||||||
|
|
||||||
def invalidate_session(self): # unused
|
def invalidate_session(self): # unused
|
||||||
r = self.session.delete(f'https://{self._oauth_host}/account/api/oauth/sessions/kill/{self.access_token}')
|
_ = self.session.delete(f'https://{self._oauth_host}/account/api/oauth/sessions/kill/{self.access_token}',
|
||||||
|
timeout=self.request_timeout)
|
||||||
|
|
||||||
def get_game_token(self):
|
def get_game_token(self):
|
||||||
r = self.session.get(f'https://{self._oauth_host}/account/api/oauth/exchange')
|
r = self.session.get(f'https://{self._oauth_host}/account/api/oauth/exchange',
|
||||||
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
@ -118,55 +153,90 @@ class EPCAPI:
|
||||||
user_id = self.user.get('account_id')
|
user_id = self.user.get('account_id')
|
||||||
r = self.session.post(f'https://{self._ecommerce_host}/ecommerceintegration/api/public/'
|
r = self.session.post(f'https://{self._ecommerce_host}/ecommerceintegration/api/public/'
|
||||||
f'platforms/EPIC/identities/{user_id}/ownershipToken',
|
f'platforms/EPIC/identities/{user_id}/ownershipToken',
|
||||||
data=dict(nsCatalogItemId=f'{namespace}:{catalog_item_id}'))
|
data=dict(nsCatalogItemId=f'{namespace}:{catalog_item_id}'),
|
||||||
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.content
|
return r.content
|
||||||
|
|
||||||
def get_external_auths(self):
|
def get_external_auths(self):
|
||||||
user_id = self.user.get('account_id')
|
user_id = self.user.get('account_id')
|
||||||
r = self.session.get(f'https://{self._oauth_host}/account/api/public/account/{user_id}/externalAuths')
|
r = self.session.get(f'https://{self._oauth_host}/account/api/public/account/{user_id}/externalAuths',
|
||||||
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def get_game_assets(self, platform='Windows', label='Live'):
|
def get_game_assets(self, platform='Windows', label='Live'):
|
||||||
r = self.session.get(f'https://{self._launcher_host}/launcher/api/public/assets/{platform}',
|
r = self.session.get(f'https://{self._launcher_host}/launcher/api/public/assets/{platform}',
|
||||||
params=dict(label=label))
|
params=dict(label=label), timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def get_game_manifest(self, namespace, catalog_item_id, app_name, platform='Windows', label='Live'):
|
def get_game_manifest(self, namespace, catalog_item_id, app_name, platform='Windows', label='Live'):
|
||||||
r = self.session.get(f'https://{self._launcher_host}/launcher/api/public/assets/v2/platform'
|
r = self.session.get(f'https://{self._launcher_host}/launcher/api/public/assets/v2/platform'
|
||||||
f'/{platform}/namespace/{namespace}/catalogItem/{catalog_item_id}/app'
|
f'/{platform}/namespace/{namespace}/catalogItem/{catalog_item_id}/app'
|
||||||
f'/{app_name}/label/{label}')
|
f'/{app_name}/label/{label}',
|
||||||
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def get_launcher_manifests(self, platform='Windows', label=None):
|
def get_launcher_manifests(self, platform='Windows', label=None):
|
||||||
r = self.session.get(f'https://{self._launcher_host}/launcher/api/public/assets/v2/platform/'
|
r = self.session.get(f'https://{self._launcher_host}/launcher/api/public/assets/v2/platform/'
|
||||||
f'{platform}/launcher',
|
f'{platform}/launcher', timeout=self.request_timeout,
|
||||||
params=dict(label=label if label else self._label))
|
params=dict(label=label if label else self._label))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def get_user_entitlements(self):
|
def get_user_entitlements(self, start=0):
|
||||||
user_id = self.user.get('account_id')
|
user_id = self.user.get('account_id')
|
||||||
r = self.session.get(f'https://{self._entitlements_host}/entitlement/api/account/{user_id}/entitlements',
|
r = self.session.get(f'https://{self._entitlements_host}/entitlement/api/account/{user_id}/entitlements',
|
||||||
params=dict(start=0, count=5000))
|
params=dict(start=start, count=1000), timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def get_user_entitlements_full(self):
|
||||||
|
ret = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = self.get_user_entitlements(start=len(ret))
|
||||||
|
ret.extend(resp)
|
||||||
|
if len(resp) < 1000:
|
||||||
|
break
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
def get_game_info(self, namespace, catalog_item_id, timeout=None):
|
def get_game_info(self, namespace, catalog_item_id, timeout=None):
|
||||||
r = self.session.get(f'https://{self._catalog_host}/catalog/api/shared/namespace/{namespace}/bulk/items',
|
r = self.session.get(f'https://{self._catalog_host}/catalog/api/shared/namespace/{namespace}/bulk/items',
|
||||||
params=dict(id=catalog_item_id, includeDLCDetails=True, includeMainGameDetails=True,
|
params=dict(id=catalog_item_id, includeDLCDetails=True, includeMainGameDetails=True,
|
||||||
country=self.country_code, locale=self.language_code),
|
country=self.country_code, locale=self.language_code),
|
||||||
timeout=timeout)
|
timeout=timeout or self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json().get(catalog_item_id, None)
|
return r.json().get(catalog_item_id, None)
|
||||||
|
|
||||||
|
def get_artifact_service_ticket(self, sandbox_id: str, artifact_id: str, label='Live', platform='Windows'):
|
||||||
|
# Based on EOS Helper Windows service implementation. Only works with anonymous EOSH session.
|
||||||
|
# sandbox_id is the same as the namespace, artifact_id is the same as the app name
|
||||||
|
r = self.session.post(f'https://{self._artifact_service_host}/artifact-service/api/public/v1/dependency/'
|
||||||
|
f'sandbox/{sandbox_id}/artifact/{artifact_id}/ticket',
|
||||||
|
json=dict(label=label, expiresInSeconds=300, platform=platform),
|
||||||
|
params=dict(useSandboxAwareLabel='false'),
|
||||||
|
timeout=self.request_timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def get_game_manifest_by_ticket(self, artifact_id: str, signed_ticket: str, label='Live', platform='Windows'):
|
||||||
|
# Based on EOS Helper Windows service implementation.
|
||||||
|
r = self.session.post(f'https://{self._launcher_host}/launcher/api/public/assets/v2/'
|
||||||
|
f'by-ticket/app/{artifact_id}',
|
||||||
|
json=dict(platform=platform, label=label, signedTicket=signed_ticket),
|
||||||
|
timeout=self.request_timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
def get_library_items(self, include_metadata=True):
|
def get_library_items(self, include_metadata=True):
|
||||||
records = []
|
records = []
|
||||||
r = self.session.get(f'https://{self._library_host}/library/api/public/items',
|
r = self.session.get(f'https://{self._library_host}/library/api/public/items',
|
||||||
params=dict(includeMetadata=include_metadata))
|
params=dict(includeMetadata=include_metadata),
|
||||||
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
j = r.json()
|
j = r.json()
|
||||||
records.extend(j['records'])
|
records.extend(j['records'])
|
||||||
|
@ -174,7 +244,8 @@ class EPCAPI:
|
||||||
# Fetch remaining library entries as long as there is a cursor
|
# Fetch remaining library entries as long as there is a cursor
|
||||||
while cursor := j['responseMetadata'].get('nextCursor', None):
|
while cursor := j['responseMetadata'].get('nextCursor', None):
|
||||||
r = self.session.get(f'https://{self._library_host}/library/api/public/items',
|
r = self.session.get(f'https://{self._library_host}/library/api/public/items',
|
||||||
params=dict(includeMetadata=include_metadata, cursor=cursor))
|
params=dict(includeMetadata=include_metadata, cursor=cursor),
|
||||||
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
j = r.json()
|
j = r.json()
|
||||||
records.extend(j['records'])
|
records.extend(j['records'])
|
||||||
|
@ -182,19 +253,20 @@ class EPCAPI:
|
||||||
return records
|
return records
|
||||||
|
|
||||||
def get_user_cloud_saves(self, app_name='', manifests=False, filenames=None):
|
def get_user_cloud_saves(self, app_name='', manifests=False, filenames=None):
|
||||||
if app_name and manifests:
|
if app_name:
|
||||||
app_name += '/manifests/'
|
app_name += '/manifests/' if manifests else '/'
|
||||||
elif app_name:
|
|
||||||
app_name += '/'
|
|
||||||
|
|
||||||
user_id = self.user.get('account_id')
|
user_id = self.user.get('account_id')
|
||||||
|
|
||||||
if filenames:
|
if filenames:
|
||||||
r = self.session.post(f'https://{self._datastorage_host}/api/v1/access/egstore/savesync/'
|
r = self.session.post(f'https://{self._datastorage_host}/api/v1/access/egstore/savesync/'
|
||||||
f'{user_id}/{app_name}', json=dict(files=filenames))
|
f'{user_id}/{app_name}',
|
||||||
|
json=dict(files=filenames),
|
||||||
|
timeout=self.request_timeout)
|
||||||
else:
|
else:
|
||||||
r = self.session.get(f'https://{self._datastorage_host}/api/v1/access/egstore/savesync/'
|
r = self.session.get(f'https://{self._datastorage_host}/api/v1/access/egstore/savesync/'
|
||||||
f'{user_id}/{app_name}')
|
f'{user_id}/{app_name}',
|
||||||
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
@ -203,32 +275,38 @@ class EPCAPI:
|
||||||
|
|
||||||
def delete_game_cloud_save_file(self, path):
|
def delete_game_cloud_save_file(self, path):
|
||||||
url = f'https://{self._datastorage_host}/api/v1/data/egstore/{path}'
|
url = f'https://{self._datastorage_host}/api/v1/data/egstore/{path}'
|
||||||
r = self.session.delete(url)
|
r = self.session.delete(url, timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
def store_get_uplay_codes(self):
|
def store_get_uplay_codes(self):
|
||||||
user_id = self.user.get('account_id')
|
user_id = self.user.get('account_id')
|
||||||
r = self.session.post(f'https://{self._store_gql_host}/graphql',
|
r = self.session.post(f'https://{self._store_gql_host}/graphql',
|
||||||
|
headers={'user-agent': self._store_user_agent},
|
||||||
json=dict(query=uplay_codes_query,
|
json=dict(query=uplay_codes_query,
|
||||||
variables=dict(accountId=user_id)))
|
variables=dict(accountId=user_id)),
|
||||||
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def store_claim_uplay_code(self, uplay_id, game_id):
|
def store_claim_uplay_code(self, uplay_id, game_id):
|
||||||
user_id = self.user.get('account_id')
|
user_id = self.user.get('account_id')
|
||||||
r = self.session.post(f'https://{self._store_gql_host}/graphql',
|
r = self.session.post(f'https://{self._store_gql_host}/graphql',
|
||||||
|
headers={'user-agent': self._store_user_agent},
|
||||||
json=dict(query=uplay_claim_query,
|
json=dict(query=uplay_claim_query,
|
||||||
variables=dict(accountId=user_id,
|
variables=dict(accountId=user_id,
|
||||||
uplayAccountId=uplay_id,
|
uplayAccountId=uplay_id,
|
||||||
gameId=game_id)))
|
gameId=game_id)),
|
||||||
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def store_redeem_uplay_codes(self, uplay_id):
|
def store_redeem_uplay_codes(self, uplay_id):
|
||||||
user_id = self.user.get('account_id')
|
user_id = self.user.get('account_id')
|
||||||
r = self.session.post(f'https://{self._store_gql_host}/graphql',
|
r = self.session.post(f'https://{self._store_gql_host}/graphql',
|
||||||
|
headers={'user-agent': self._store_user_agent},
|
||||||
json=dict(query=uplay_redeem_query,
|
json=dict(query=uplay_redeem_query,
|
||||||
variables=dict(accountId=user_id,
|
variables=dict(accountId=user_id,
|
||||||
uplayAccountId=uplay_id)))
|
uplayAccountId=uplay_id)),
|
||||||
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
1156
legendary/cli.py
1156
legendary/cli.py
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -22,14 +22,14 @@ from legendary.models.manifest import ManifestComparison, Manifest
|
||||||
class DLManager(Process):
|
class DLManager(Process):
|
||||||
def __init__(self, download_dir, base_url, cache_dir=None, status_q=None,
|
def __init__(self, download_dir, base_url, cache_dir=None, status_q=None,
|
||||||
max_workers=0, update_interval=1.0, dl_timeout=10, resume_file=None,
|
max_workers=0, update_interval=1.0, dl_timeout=10, resume_file=None,
|
||||||
max_shared_memory=1024 * 1024 * 1024):
|
max_shared_memory=1024 * 1024 * 1024, bind_ip=None):
|
||||||
super().__init__(name='DLManager')
|
super().__init__(name='DLManager')
|
||||||
self.log = logging.getLogger('DLM')
|
self.log = logging.getLogger('DLM')
|
||||||
self.proc_debug = False
|
self.proc_debug = False
|
||||||
|
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
self.dl_dir = download_dir
|
self.dl_dir = download_dir
|
||||||
self.cache_dir = cache_dir if cache_dir else os.path.join(download_dir, '.cache')
|
self.cache_dir = cache_dir or os.path.join(download_dir, '.cache')
|
||||||
|
|
||||||
# All the queues!
|
# All the queues!
|
||||||
self.logging_queue = None
|
self.logging_queue = None
|
||||||
|
@ -37,8 +37,11 @@ class DLManager(Process):
|
||||||
self.writer_queue = None
|
self.writer_queue = None
|
||||||
self.dl_result_q = None
|
self.dl_result_q = None
|
||||||
self.writer_result_q = None
|
self.writer_result_q = None
|
||||||
self.max_workers = max_workers if max_workers else min(cpu_count() * 2, 16)
|
|
||||||
|
# Worker stuff
|
||||||
|
self.max_workers = max_workers or min(cpu_count() * 2, 16)
|
||||||
self.dl_timeout = dl_timeout
|
self.dl_timeout = dl_timeout
|
||||||
|
self.bind_ips = [] if not bind_ip else bind_ip.split(',')
|
||||||
|
|
||||||
# Analysis stuff
|
# Analysis stuff
|
||||||
self.analysis = None
|
self.analysis = None
|
||||||
|
@ -137,6 +140,24 @@ class DLManager(Process):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(f'Reading resume file failed: {e!r}, continuing as normal...')
|
self.log.warning(f'Reading resume file failed: {e!r}, continuing as normal...')
|
||||||
|
|
||||||
|
elif resume:
|
||||||
|
# Basic check if files exist locally, put all missing files into "added"
|
||||||
|
# This allows new SDL tags to be installed without having to do a repair as well.
|
||||||
|
missing_files = set()
|
||||||
|
|
||||||
|
for fm in manifest.file_manifest_list.elements:
|
||||||
|
if fm.filename in mc.added:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_path = os.path.join(self.dl_dir, fm.filename)
|
||||||
|
if not os.path.exists(local_path):
|
||||||
|
missing_files.add(fm.filename)
|
||||||
|
|
||||||
|
self.log.info(f'Found {len(missing_files)} missing files.')
|
||||||
|
mc.added |= missing_files
|
||||||
|
mc.changed -= missing_files
|
||||||
|
mc.unchanged -= missing_files
|
||||||
|
|
||||||
# Install tags are used for selective downloading, e.g. for language packs
|
# Install tags are used for selective downloading, e.g. for language packs
|
||||||
additional_deletion_tasks = []
|
additional_deletion_tasks = []
|
||||||
if file_install_tag is not None:
|
if file_install_tag is not None:
|
||||||
|
@ -208,6 +229,8 @@ class DLManager(Process):
|
||||||
fmlist = sorted(manifest.file_manifest_list.elements,
|
fmlist = sorted(manifest.file_manifest_list.elements,
|
||||||
key=lambda a: a.filename.lower())
|
key=lambda a: a.filename.lower())
|
||||||
|
|
||||||
|
# Create reference count for chunks and calculate additional/temporary disk size required for install
|
||||||
|
current_tmp_size = 0
|
||||||
for fm in fmlist:
|
for fm in fmlist:
|
||||||
self.hash_map[fm.filename] = fm.sha_hash.hex()
|
self.hash_map[fm.filename] = fm.sha_hash.hex()
|
||||||
|
|
||||||
|
@ -219,6 +242,20 @@ class DLManager(Process):
|
||||||
for cp in fm.chunk_parts:
|
for cp in fm.chunk_parts:
|
||||||
references[cp.guid_num] += 1
|
references[cp.guid_num] += 1
|
||||||
|
|
||||||
|
if fm.filename in mc.added:
|
||||||
|
# if the file was added, it just adds to the delta
|
||||||
|
current_tmp_size += fm.file_size
|
||||||
|
analysis_res.disk_space_delta = max(current_tmp_size, analysis_res.disk_space_delta)
|
||||||
|
elif fm.filename in mc.changed:
|
||||||
|
# if the file was changed, we need temporary space equal to the full size,
|
||||||
|
# but then subtract the size of the old file as it's deleted on write completion.
|
||||||
|
current_tmp_size += fm.file_size
|
||||||
|
analysis_res.disk_space_delta = max(current_tmp_size, analysis_res.disk_space_delta)
|
||||||
|
current_tmp_size -= old_manifest.file_manifest_list.get_file_by_path(fm.filename).file_size
|
||||||
|
|
||||||
|
# clamp to 0
|
||||||
|
self.log.debug(f'Disk space delta: {analysis_res.disk_space_delta/1024/1024:.02f} MiB')
|
||||||
|
|
||||||
if processing_optimization:
|
if processing_optimization:
|
||||||
s_time = time.time()
|
s_time = time.time()
|
||||||
# reorder the file manifest list to group files that share many chunks
|
# reorder the file manifest list to group files that share many chunks
|
||||||
|
@ -585,6 +622,12 @@ class DLManager(Process):
|
||||||
if t.is_alive():
|
if t.is_alive():
|
||||||
self.log.warning(f'Thread did not terminate! {repr(t)}')
|
self.log.warning(f'Thread did not terminate! {repr(t)}')
|
||||||
|
|
||||||
|
# forcibly kill DL workers that are not actually dead yet
|
||||||
|
for child in self.children:
|
||||||
|
child.join(timeout=5.0)
|
||||||
|
if child.exitcode is None:
|
||||||
|
child.terminate()
|
||||||
|
|
||||||
# clean up all the queues, otherwise this process won't terminate properly
|
# clean up all the queues, otherwise this process won't terminate properly
|
||||||
for name, q in zip(('Download jobs', 'Writer jobs', 'Download results', 'Writer results'),
|
for name, q in zip(('Download jobs', 'Writer jobs', 'Download results', 'Writer results'),
|
||||||
(self.dl_worker_queue, self.writer_queue, self.dl_result_q, self.writer_result_q)):
|
(self.dl_worker_queue, self.writer_queue, self.dl_result_q, self.writer_result_q)):
|
||||||
|
@ -615,10 +658,15 @@ class DLManager(Process):
|
||||||
self.writer_result_q = MPQueue(-1)
|
self.writer_result_q = MPQueue(-1)
|
||||||
|
|
||||||
self.log.info(f'Starting download workers...')
|
self.log.info(f'Starting download workers...')
|
||||||
|
|
||||||
|
bind_ip = None
|
||||||
for i in range(self.max_workers):
|
for i in range(self.max_workers):
|
||||||
|
if self.bind_ips:
|
||||||
|
bind_ip = self.bind_ips[i % len(self.bind_ips)]
|
||||||
|
|
||||||
w = DLWorker(f'DLWorker {i + 1}', self.dl_worker_queue, self.dl_result_q,
|
w = DLWorker(f'DLWorker {i + 1}', self.dl_worker_queue, self.dl_result_q,
|
||||||
self.shared_memory.name, logging_queue=self.logging_queue,
|
self.shared_memory.name, logging_queue=self.logging_queue,
|
||||||
dl_timeout=self.dl_timeout)
|
dl_timeout=self.dl_timeout, bind_addr=bind_ip)
|
||||||
self.children.append(w)
|
self.children.append(w)
|
||||||
w.start()
|
w.start()
|
||||||
|
|
||||||
|
@ -704,7 +752,7 @@ class DLManager(Process):
|
||||||
f'ETA: {hours:02d}:{minutes:02d}:{seconds:02d}')
|
f'ETA: {hours:02d}:{minutes:02d}:{seconds:02d}')
|
||||||
self.log.info(f' - Downloaded: {total_dl / 1024 / 1024:.02f} MiB, '
|
self.log.info(f' - Downloaded: {total_dl / 1024 / 1024:.02f} MiB, '
|
||||||
f'Written: {total_write / 1024 / 1024:.02f} MiB')
|
f'Written: {total_write / 1024 / 1024:.02f} MiB')
|
||||||
self.log.info(f' - Cache usage: {total_used} MiB, active tasks: {self.active_tasks}')
|
self.log.info(f' - Cache usage: {total_used:.02f} MiB, active tasks: {self.active_tasks}')
|
||||||
self.log.info(f' + Download\t- {dl_speed / 1024 / 1024:.02f} MiB/s (raw) '
|
self.log.info(f' + Download\t- {dl_speed / 1024 / 1024:.02f} MiB/s (raw) '
|
||||||
f'/ {dl_unc_speed / 1024 / 1024:.02f} MiB/s (decompressed)')
|
f'/ {dl_unc_speed / 1024 / 1024:.02f} MiB/s (decompressed)')
|
||||||
self.log.info(f' + Disk\t- {w_speed / 1024 / 1024:.02f} MiB/s (write) / '
|
self.log.info(f' + Disk\t- {w_speed / 1024 / 1024:.02f} MiB/s (write) / '
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import requests
|
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -10,6 +9,9 @@ from multiprocessing import Process
|
||||||
from multiprocessing.shared_memory import SharedMemory
|
from multiprocessing.shared_memory import SharedMemory
|
||||||
from queue import Empty
|
from queue import Empty
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter, DEFAULT_POOLBLOCK
|
||||||
|
|
||||||
from legendary.models.chunk import Chunk
|
from legendary.models.chunk import Chunk
|
||||||
from legendary.models.downloading import (
|
from legendary.models.downloading import (
|
||||||
DownloaderTask, DownloaderTaskResult,
|
DownloaderTask, DownloaderTaskResult,
|
||||||
|
@ -18,9 +20,22 @@ from legendary.models.downloading import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BindingHTTPAdapter(HTTPAdapter):
|
||||||
|
def __init__(self, addr):
|
||||||
|
self.__attrs__.append('addr')
|
||||||
|
self.addr = addr
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def init_poolmanager(
|
||||||
|
self, connections, maxsize, block=DEFAULT_POOLBLOCK, **pool_kwargs
|
||||||
|
):
|
||||||
|
pool_kwargs['source_address'] = (self.addr, 0)
|
||||||
|
super().init_poolmanager(connections, maxsize, block, **pool_kwargs)
|
||||||
|
|
||||||
|
|
||||||
class DLWorker(Process):
|
class DLWorker(Process):
|
||||||
def __init__(self, name, queue, out_queue, shm, max_retries=7,
|
def __init__(self, name, queue, out_queue, shm, max_retries=7,
|
||||||
logging_queue=None, dl_timeout=10):
|
logging_queue=None, dl_timeout=10, bind_addr=None):
|
||||||
super().__init__(name=name)
|
super().__init__(name=name)
|
||||||
self.q = queue
|
self.q = queue
|
||||||
self.o_q = out_queue
|
self.o_q = out_queue
|
||||||
|
@ -34,6 +49,12 @@ class DLWorker(Process):
|
||||||
self.logging_queue = logging_queue
|
self.logging_queue = logging_queue
|
||||||
self.dl_timeout = float(dl_timeout) if dl_timeout else 10.0
|
self.dl_timeout = float(dl_timeout) if dl_timeout else 10.0
|
||||||
|
|
||||||
|
# optionally bind an address
|
||||||
|
if bind_addr:
|
||||||
|
adapter = BindingHTTPAdapter(bind_addr)
|
||||||
|
self.session.mount('https://', adapter)
|
||||||
|
self.session.mount('http://', adapter)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
# we have to fix up the logger before we can start
|
# we have to fix up the logger before we can start
|
||||||
_root = logging.getLogger()
|
_root = logging.getLogger()
|
||||||
|
@ -51,12 +72,12 @@ class DLWorker(Process):
|
||||||
empty = False
|
empty = False
|
||||||
except Empty:
|
except Empty:
|
||||||
if not empty:
|
if not empty:
|
||||||
logger.debug(f'Queue Empty, waiting for more...')
|
logger.debug('Queue Empty, waiting for more...')
|
||||||
empty = True
|
empty = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(job, TerminateWorkerTask): # let worker die
|
if isinstance(job, TerminateWorkerTask): # let worker die
|
||||||
logger.debug(f'Worker received termination signal, shutting down...')
|
logger.debug('Worker received termination signal, shutting down...')
|
||||||
break
|
break
|
||||||
|
|
||||||
tries = 0
|
tries = 0
|
||||||
|
@ -99,17 +120,18 @@ class DLWorker(Process):
|
||||||
break
|
break
|
||||||
|
|
||||||
if not chunk:
|
if not chunk:
|
||||||
logger.warning(f'Chunk somehow None?')
|
logger.warning('Chunk somehow None?')
|
||||||
self.o_q.put(DownloaderTaskResult(success=False, **job.__dict__))
|
self.o_q.put(DownloaderTaskResult(success=False, **job.__dict__))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# decompress stuff
|
# decompress stuff
|
||||||
try:
|
try:
|
||||||
size = len(chunk.data)
|
data = chunk.data
|
||||||
|
size = len(data)
|
||||||
if size > job.shm.size:
|
if size > job.shm.size:
|
||||||
logger.fatal(f'Downloaded chunk is longer than SharedMemorySegment!')
|
logger.fatal('Downloaded chunk is longer than SharedMemorySegment!')
|
||||||
|
|
||||||
self.shm.buf[job.shm.offset:job.shm.offset + size] = bytes(chunk.data)
|
self.shm.buf[job.shm.offset:job.shm.offset + size] = data
|
||||||
del chunk
|
del chunk
|
||||||
self.o_q.put(DownloaderTaskResult(success=True, size_decompressed=size,
|
self.o_q.put(DownloaderTaskResult(success=True, size_decompressed=size,
|
||||||
size_downloaded=compressed, **job.__dict__))
|
size_downloaded=compressed, **job.__dict__))
|
||||||
|
@ -130,7 +152,7 @@ class FileWorker(Process):
|
||||||
self.q = queue
|
self.q = queue
|
||||||
self.o_q = out_queue
|
self.o_q = out_queue
|
||||||
self.base_path = base_path
|
self.base_path = base_path
|
||||||
self.cache_path = cache_path if cache_path else os.path.join(base_path, '.cache')
|
self.cache_path = cache_path or os.path.join(base_path, '.cache')
|
||||||
self.shm = SharedMemory(name=shm)
|
self.shm = SharedMemory(name=shm)
|
||||||
self.log_level = logging.getLogger().level
|
self.log_level = logging.getLogger().level
|
||||||
self.logging_queue = logging_queue
|
self.logging_queue = logging_queue
|
||||||
|
@ -143,7 +165,7 @@ class FileWorker(Process):
|
||||||
|
|
||||||
logger = logging.getLogger(self.name)
|
logger = logging.getLogger(self.name)
|
||||||
logger.setLevel(self.log_level)
|
logger.setLevel(self.log_level)
|
||||||
logger.debug(f'Download worker reporting for duty!')
|
logger.debug('Download worker reporting for duty!')
|
||||||
|
|
||||||
last_filename = ''
|
last_filename = ''
|
||||||
current_file = None
|
current_file = None
|
||||||
|
@ -159,7 +181,7 @@ class FileWorker(Process):
|
||||||
if isinstance(j, TerminateWorkerTask):
|
if isinstance(j, TerminateWorkerTask):
|
||||||
if current_file:
|
if current_file:
|
||||||
current_file.close()
|
current_file.close()
|
||||||
logger.debug(f'Worker received termination signal, shutting down...')
|
logger.debug('Worker received termination signal, shutting down...')
|
||||||
# send termination task to results halnder as well
|
# send termination task to results halnder as well
|
||||||
self.o_q.put(TerminateWorkerTask())
|
self.o_q.put(TerminateWorkerTask())
|
||||||
break
|
break
|
||||||
|
@ -250,7 +272,7 @@ class FileWorker(Process):
|
||||||
if j.shared_memory:
|
if j.shared_memory:
|
||||||
shm_offset = j.shared_memory.offset + j.chunk_offset
|
shm_offset = j.shared_memory.offset + j.chunk_offset
|
||||||
shm_end = shm_offset + j.chunk_size
|
shm_end = shm_offset + j.chunk_size
|
||||||
current_file.write(self.shm.buf[shm_offset:shm_end].tobytes())
|
current_file.write(self.shm.buf[shm_offset:shm_end])
|
||||||
elif j.cache_file:
|
elif j.cache_file:
|
||||||
with open(os.path.join(self.cache_path, j.cache_file), 'rb') as f:
|
with open(os.path.join(self.cache_path, j.cache_file), 'rb') as f:
|
||||||
if j.chunk_offset:
|
if j.chunk_offset:
|
||||||
|
|
62
legendary/lfs/crossover.py
Normal file
62
legendary/lfs/crossover.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import logging
|
||||||
|
import plistlib
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
_logger = logging.getLogger('CXHelpers')
|
||||||
|
|
||||||
|
|
||||||
|
def mac_get_crossover_version(app_path):
|
||||||
|
try:
|
||||||
|
plist = plistlib.load(open(os.path.join(app_path, 'Contents', 'Info.plist'), 'rb'))
|
||||||
|
return plist['CFBundleShortVersionString']
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug(f'Failed to load plist for "{app_path}" with {e!r}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mac_find_crossover_apps():
|
||||||
|
paths = ['/Applications/CrossOver.app']
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(['mdfind', 'kMDItemCFBundleIdentifier="com.codeweavers.CrossOver"'])
|
||||||
|
paths.extend(out.decode('utf-8', 'replace').strip().split('\n'))
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(f'Trying to find CrossOver installs via mdfind failed: {e!r}')
|
||||||
|
|
||||||
|
valid = [p for p in paths if os.path.exists(os.path.join(p, 'Contents', 'Info.plist'))]
|
||||||
|
found_tuples = set()
|
||||||
|
|
||||||
|
for path in valid:
|
||||||
|
version = mac_get_crossover_version(path)
|
||||||
|
if not version:
|
||||||
|
continue
|
||||||
|
_logger.debug(f'Found Crossover {version} at "{path}"')
|
||||||
|
found_tuples.add((version, path))
|
||||||
|
|
||||||
|
return sorted(found_tuples, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def mac_get_crossover_bottles():
|
||||||
|
bottles_path = os.path.expanduser('~/Library/Application Support/CrossOver/Bottles')
|
||||||
|
if not os.path.exists(bottles_path):
|
||||||
|
return []
|
||||||
|
return sorted(p for p in os.listdir(bottles_path) if mac_is_valid_bottle(p))
|
||||||
|
|
||||||
|
|
||||||
|
def mac_is_valid_bottle(bottle_name):
|
||||||
|
bottles_path = os.path.expanduser('~/Library/Application Support/CrossOver/Bottles')
|
||||||
|
return os.path.exists(os.path.join(bottles_path, bottle_name, 'cxbottle.conf'))
|
||||||
|
|
||||||
|
|
||||||
|
def mac_get_bottle_path(bottle_name):
|
||||||
|
bottles_path = os.path.expanduser('~/Library/Application Support/CrossOver/Bottles')
|
||||||
|
return os.path.join(bottles_path, bottle_name)
|
||||||
|
|
||||||
|
|
||||||
|
def mac_is_crossover_running():
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(['launchctl', 'list'])
|
||||||
|
return b'com.codeweavers.CrossOver.' in out
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(f'Getting list of running application bundles failed: {e!r}')
|
||||||
|
return True # assume the worst
|
|
@ -34,22 +34,32 @@ class EPCLFS:
|
||||||
if not self.appdata_path:
|
if not self.appdata_path:
|
||||||
raise ValueError('EGS AppData path is not set')
|
raise ValueError('EGS AppData path is not set')
|
||||||
|
|
||||||
self.config.read(os.path.join(self.appdata_path, 'GameUserSettings.ini'))
|
if not os.path.isdir(self.appdata_path):
|
||||||
|
raise ValueError('EGS AppData path does not exist')
|
||||||
|
|
||||||
|
self.config.read(os.path.join(self.appdata_path, 'GameUserSettings.ini'), encoding='utf-8')
|
||||||
|
|
||||||
def save_config(self):
|
def save_config(self):
|
||||||
if not self.appdata_path:
|
if not self.appdata_path:
|
||||||
raise ValueError('EGS AppData path is not set')
|
raise ValueError('EGS AppData path is not set')
|
||||||
|
|
||||||
with open(os.path.join(self.appdata_path, 'GameUserSettings.ini'), 'w') as f:
|
if not os.path.isdir(self.appdata_path):
|
||||||
|
raise ValueError('EGS AppData path does not exist')
|
||||||
|
|
||||||
|
with open(os.path.join(self.appdata_path, 'GameUserSettings.ini'), 'w', encoding='utf-8') as f:
|
||||||
self.config.write(f, space_around_delimiters=False)
|
self.config.write(f, space_around_delimiters=False)
|
||||||
|
|
||||||
def read_manifests(self):
|
def read_manifests(self):
|
||||||
if not self.programdata_path:
|
if not self.programdata_path:
|
||||||
raise ValueError('EGS ProgramData path is not set')
|
raise ValueError('EGS ProgramData path is not set')
|
||||||
|
|
||||||
|
if not os.path.isdir(self.programdata_path):
|
||||||
|
# Not sure if we should `raise` here as well
|
||||||
|
return
|
||||||
|
|
||||||
for f in os.listdir(self.programdata_path):
|
for f in os.listdir(self.programdata_path):
|
||||||
if f.endswith('.item'):
|
if f.endswith('.item'):
|
||||||
data = json.load(open(os.path.join(self.programdata_path, f)))
|
data = json.load(open(os.path.join(self.programdata_path, f), encoding='utf-8'))
|
||||||
self.manifests[data['AppName']] = data
|
self.manifests[data['AppName']] = data
|
||||||
|
|
||||||
def get_manifests(self) -> List[EGLManifest]:
|
def get_manifests(self) -> List[EGLManifest]:
|
||||||
|
@ -71,9 +81,13 @@ class EPCLFS:
|
||||||
if not self.programdata_path:
|
if not self.programdata_path:
|
||||||
raise ValueError('EGS ProgramData path is not set')
|
raise ValueError('EGS ProgramData path is not set')
|
||||||
|
|
||||||
|
if not os.path.isdir(self.programdata_path):
|
||||||
|
raise ValueError('EGS ProgramData path does not exist')
|
||||||
|
|
||||||
manifest_data = manifest.to_json()
|
manifest_data = manifest.to_json()
|
||||||
self.manifests[manifest.app_name] = manifest_data
|
self.manifests[manifest.app_name] = manifest_data
|
||||||
with open(os.path.join(self.programdata_path, f'{manifest.installation_guid}.item'), 'w') as f:
|
_path = os.path.join(self.programdata_path, f'{manifest.installation_guid}.item')
|
||||||
|
with open(_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(manifest_data, f, indent=4, sort_keys=True)
|
json.dump(manifest_data, f, indent=4, sort_keys=True)
|
||||||
|
|
||||||
def delete_manifest(self, app_name):
|
def delete_manifest(self, app_name):
|
||||||
|
|
147
legendary/lfs/eos.py
Normal file
147
legendary/lfs/eos.py
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from legendary.models.game import Game
|
||||||
|
|
||||||
|
if os.name == 'nt':
|
||||||
|
from legendary.lfs.windows_helpers import *
|
||||||
|
|
||||||
|
logger = logging.getLogger('EOSUtils')
|
||||||
|
# Dummy Game objects to use with Core methods that expect them
|
||||||
|
# Overlay
|
||||||
|
EOSOverlayApp = Game(app_name='98bc04bc842e4906993fd6d6644ffb8d',
|
||||||
|
app_title='Epic Online Services Overlay',
|
||||||
|
metadata=dict(namespace='302e5ede476149b1bc3e4fe6ae45e50e',
|
||||||
|
id='cc15684f44d849e89e9bf4cec0508b68'))
|
||||||
|
# EOS Windows service
|
||||||
|
EOSHApp = Game(app_name='c9e2eb9993a1496c99dc529b49a07339',
|
||||||
|
app_title='Epic Online Services Helper (EOSH)',
|
||||||
|
metadata=dict(namespace='302e5ede476149b1bc3e4fe6ae45e50e',
|
||||||
|
id='1108a9c0af47438da91331753b22ea21'))
|
||||||
|
|
||||||
|
EOS_OVERLAY_KEY = r'SOFTWARE\Epic Games\EOS'
|
||||||
|
WINE_EOS_OVERLAY_KEY = EOS_OVERLAY_KEY.replace('\\', '\\\\')
|
||||||
|
EOS_OVERLAY_VALUE = 'OverlayPath'
|
||||||
|
VULKAN_OVERLAY_KEY = r'SOFTWARE\Khronos\Vulkan\ImplicitLayers'
|
||||||
|
|
||||||
|
|
||||||
|
def query_registry_entries(prefix=None):
|
||||||
|
if os.name == 'nt':
|
||||||
|
# Overlay location for the EOS SDK to load
|
||||||
|
overlay_path = query_registry_value(HKEY_CURRENT_USER, EOS_OVERLAY_KEY, EOS_OVERLAY_VALUE)
|
||||||
|
# Vulkan Layers
|
||||||
|
# HKCU
|
||||||
|
vulkan_hkcu = [i[0] for i in
|
||||||
|
list_registry_values(HKEY_CURRENT_USER, VULKAN_OVERLAY_KEY)
|
||||||
|
if 'EOS' in i[0]]
|
||||||
|
# HKLM 64 & 32 bit
|
||||||
|
vulkan_hklm = [i[0] for i in
|
||||||
|
list_registry_values(HKEY_LOCAL_MACHINE, VULKAN_OVERLAY_KEY)
|
||||||
|
if 'EOS' in i[0]]
|
||||||
|
vulkan_hklm += [i[0] for i in
|
||||||
|
list_registry_values(HKEY_LOCAL_MACHINE, VULKAN_OVERLAY_KEY, use_32bit_view=True)
|
||||||
|
if 'EOS' in i[0]]
|
||||||
|
|
||||||
|
return dict(overlay_path=overlay_path,
|
||||||
|
vulkan_hkcu=vulkan_hkcu,
|
||||||
|
vulkan_hklm=vulkan_hklm)
|
||||||
|
elif prefix:
|
||||||
|
# Only read HKCU since we don't really care for the Vulkan stuff (doesn't work in WINE)
|
||||||
|
use_reg_file = os.path.join(prefix, 'user.reg')
|
||||||
|
if not os.path.exists(use_reg_file):
|
||||||
|
raise ValueError('No user.reg file, invalid path')
|
||||||
|
|
||||||
|
reg_lines = open(use_reg_file, 'r', encoding='utf-8').readlines()
|
||||||
|
for line in reg_lines:
|
||||||
|
if EOS_OVERLAY_VALUE in line:
|
||||||
|
overlay_path = line.partition('=')[2].strip().strip('"')
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
overlay_path = None
|
||||||
|
|
||||||
|
if overlay_path:
|
||||||
|
if overlay_path.startswith('C:'):
|
||||||
|
overlay_path = os.path.join(prefix, 'drive_c', overlay_path[3:])
|
||||||
|
elif overlay_path.startswith('Z:'):
|
||||||
|
overlay_path = overlay_path[2:]
|
||||||
|
|
||||||
|
return dict(overlay_path=overlay_path,
|
||||||
|
vulkan_hkcu=list(),
|
||||||
|
vulkan_hklm=list())
|
||||||
|
else:
|
||||||
|
raise ValueError('No prefix specified on non-Windows platform')
|
||||||
|
|
||||||
|
|
||||||
|
def add_registry_entries(overlay_path, prefix=None):
|
||||||
|
if os.name == 'nt':
|
||||||
|
logger.debug(f'Settings HKCU EOS Overlay Path: {overlay_path}')
|
||||||
|
set_registry_value(HKEY_CURRENT_USER, EOS_OVERLAY_KEY, EOS_OVERLAY_VALUE,
|
||||||
|
overlay_path.replace('\\', '/'), TYPE_STRING)
|
||||||
|
vk_32_path = os.path.join(overlay_path, 'EOSOverlayVkLayer-Win32.json').replace('/', '\\')
|
||||||
|
vk_64_path = os.path.join(overlay_path, 'EOSOverlayVkLayer-Win64.json').replace('/', '\\')
|
||||||
|
# the launcher only sets those in HKCU, th e service sets them in HKLM,
|
||||||
|
# but it's not in use yet, so just do HKCU for now
|
||||||
|
logger.debug(f'Settings HKCU 32-bit Vulkan Layer: {vk_32_path}')
|
||||||
|
set_registry_value(HKEY_CURRENT_USER, VULKAN_OVERLAY_KEY, vk_32_path, 0, TYPE_DWORD)
|
||||||
|
logger.debug(f'Settings HKCU 64-bit Vulkan Layer: {vk_32_path}')
|
||||||
|
set_registry_value(HKEY_CURRENT_USER, VULKAN_OVERLAY_KEY, vk_64_path, 0, TYPE_DWORD)
|
||||||
|
elif prefix:
|
||||||
|
# Again only care for HKCU OverlayPath because Windows Vulkan layers don't work anyway
|
||||||
|
use_reg_file = os.path.join(prefix, 'user.reg')
|
||||||
|
if not os.path.exists(use_reg_file):
|
||||||
|
raise ValueError('No user.reg file, invalid path')
|
||||||
|
|
||||||
|
reg_lines = open(use_reg_file, 'r', encoding='utf-8').readlines()
|
||||||
|
|
||||||
|
overlay_path = overlay_path.replace('\\', '/')
|
||||||
|
if overlay_path.startswith('/'):
|
||||||
|
overlay_path = f'Z:{overlay_path}'
|
||||||
|
|
||||||
|
overlay_line = f'"{EOS_OVERLAY_VALUE}"="{overlay_path}"\n'
|
||||||
|
overlay_idx = None
|
||||||
|
section_idx = None
|
||||||
|
|
||||||
|
for idx, line in enumerate(reg_lines):
|
||||||
|
if EOS_OVERLAY_VALUE in line:
|
||||||
|
reg_lines[idx] = overlay_line
|
||||||
|
break
|
||||||
|
elif WINE_EOS_OVERLAY_KEY in line:
|
||||||
|
section_idx = idx
|
||||||
|
else:
|
||||||
|
if section_idx:
|
||||||
|
reg_lines.insert(section_idx + 1, overlay_line)
|
||||||
|
else:
|
||||||
|
reg_lines.append(f'[{WINE_EOS_OVERLAY_KEY}]\n')
|
||||||
|
reg_lines.append(overlay_line)
|
||||||
|
|
||||||
|
open(use_reg_file, 'w', encoding='utf-8').writelines(reg_lines)
|
||||||
|
else:
|
||||||
|
raise ValueError('No prefix specified on non-Windows platform')
|
||||||
|
|
||||||
|
|
||||||
|
def remove_registry_entries(prefix=None):
|
||||||
|
entries = query_registry_entries(prefix)
|
||||||
|
|
||||||
|
if os.name == 'nt':
|
||||||
|
if entries['overlay_path']:
|
||||||
|
logger.debug('Removing HKCU EOS OverlayPath')
|
||||||
|
remove_registry_value(HKEY_CURRENT_USER, EOS_OVERLAY_KEY, EOS_OVERLAY_VALUE)
|
||||||
|
for value in entries['vulkan_hkcu']:
|
||||||
|
logger.debug(f'Removing HKCU Vulkan Layer: {value}')
|
||||||
|
remove_registry_value(HKEY_CURRENT_USER, VULKAN_OVERLAY_KEY, value)
|
||||||
|
for value in entries['vulkan_hklm']:
|
||||||
|
logger.debug(f'Removing HKLM Vulkan Layer: {value}')
|
||||||
|
remove_registry_value(HKEY_LOCAL_MACHINE, VULKAN_OVERLAY_KEY, value)
|
||||||
|
remove_registry_value(HKEY_LOCAL_MACHINE, VULKAN_OVERLAY_KEY, value, use_32bit_view=True)
|
||||||
|
elif prefix:
|
||||||
|
# Same as above, only HKCU.
|
||||||
|
use_reg_file = os.path.join(prefix, 'user.reg')
|
||||||
|
if not os.path.exists(use_reg_file):
|
||||||
|
raise ValueError('No user.reg file, invalid path')
|
||||||
|
|
||||||
|
if entries['overlay_path']:
|
||||||
|
reg_lines = open(use_reg_file, 'r', encoding='utf-8').readlines()
|
||||||
|
filtered_lines = [line for line in reg_lines if EOS_OVERLAY_VALUE not in line]
|
||||||
|
open(use_reg_file, 'w', encoding='utf-8').writelines(filtered_lines)
|
||||||
|
else:
|
||||||
|
raise ValueError('No prefix specified on non-Windows platform')
|
|
@ -4,22 +4,31 @@ import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
|
from filelock import FileLock
|
||||||
|
|
||||||
|
from .utils import clean_filename, LockedJSONData
|
||||||
|
|
||||||
from legendary.models.game import *
|
from legendary.models.game import *
|
||||||
from legendary.utils.aliasing import generate_aliases
|
from legendary.utils.aliasing import generate_aliases
|
||||||
from legendary.utils.config import LGDConf
|
from legendary.models.config import LGDConf
|
||||||
from legendary.utils.env import is_windows_mac_or_pyi
|
from legendary.utils.env import is_windows_mac_or_pyi
|
||||||
from legendary.utils.lfs import clean_filename
|
|
||||||
|
|
||||||
|
FILELOCK_DEBUG = False
|
||||||
|
|
||||||
|
|
||||||
class LGDLFS:
|
class LGDLFS:
|
||||||
def __init__(self, config_file=None):
|
def __init__(self, config_file=None):
|
||||||
self.log = logging.getLogger('LGDLFS')
|
self.log = logging.getLogger('LGDLFS')
|
||||||
|
|
||||||
if config_path := os.environ.get('XDG_CONFIG_HOME'):
|
if config_path := os.environ.get('LEGENDARY_CONFIG_PATH'):
|
||||||
|
self.path = config_path
|
||||||
|
elif config_path := os.environ.get('XDG_CONFIG_HOME'):
|
||||||
self.path = os.path.join(config_path, 'legendary')
|
self.path = os.path.join(config_path, 'legendary')
|
||||||
else:
|
else:
|
||||||
self.path = os.path.expanduser('~/.config/legendary')
|
self.path = os.path.expanduser('~/.config/legendary')
|
||||||
|
@ -34,6 +43,9 @@ class LGDLFS:
|
||||||
self._game_metadata = dict()
|
self._game_metadata = dict()
|
||||||
# Legendary update check info
|
# Legendary update check info
|
||||||
self._update_info = None
|
self._update_info = None
|
||||||
|
# EOS Overlay install/update check info
|
||||||
|
self._overlay_update_info = None
|
||||||
|
self._overlay_install_info = None
|
||||||
# Config with game specific settings (e.g. start parameters, env variables)
|
# Config with game specific settings (e.g. start parameters, env variables)
|
||||||
self.config = LGDConf(comment_prefixes='/', allow_no_value=True)
|
self.config = LGDConf(comment_prefixes='/', allow_no_value=True)
|
||||||
|
|
||||||
|
@ -80,13 +92,18 @@ class LGDLFS:
|
||||||
self.log.warning(f'Removing "{os.path.join(self.path, "manifests", "old")}" folder failed: '
|
self.log.warning(f'Removing "{os.path.join(self.path, "manifests", "old")}" folder failed: '
|
||||||
f'{e!r}, please remove manually')
|
f'{e!r}, please remove manually')
|
||||||
|
|
||||||
|
if not FILELOCK_DEBUG:
|
||||||
|
# Prevent filelock logger from spamming Legendary debug output
|
||||||
|
filelock_logger = logging.getLogger('filelock')
|
||||||
|
filelock_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
# try loading config
|
# try loading config
|
||||||
try:
|
try:
|
||||||
self.config.read(self.config_path)
|
self.config.read(self.config_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error(f'Unable to read configuration file, please ensure that file is valid! '
|
self.log.error(f'Unable to read configuration file, please ensure that file is valid! '
|
||||||
f'(Error: {repr(e)})')
|
f'(Error: {repr(e)})')
|
||||||
self.log.warning(f'Continuing with blank config in safe-mode...')
|
self.log.warning('Continuing with blank config in safe-mode...')
|
||||||
self.config.read_only = True
|
self.config.read_only = True
|
||||||
|
|
||||||
# make sure "Legendary" section exists
|
# make sure "Legendary" section exists
|
||||||
|
@ -101,6 +118,8 @@ class LGDLFS:
|
||||||
self.config.set('Legendary', '; Disables the notice about an available update on exit')
|
self.config.set('Legendary', '; Disables the notice about an available update on exit')
|
||||||
self.config.set('Legendary', 'disable_update_notice', 'false' if is_windows_mac_or_pyi() else 'true')
|
self.config.set('Legendary', 'disable_update_notice', 'false' if is_windows_mac_or_pyi() else 'true')
|
||||||
|
|
||||||
|
self._installed_lock = FileLock(os.path.join(self.path, 'installed.json') + '.lock')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._installed = json.load(open(os.path.join(self.path, 'installed.json')))
|
self._installed = json.load(open(os.path.join(self.path, 'installed.json')))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -126,31 +145,35 @@ class LGDLFS:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.debug(f'Loading aliases failed with {e!r}')
|
self.log.debug(f'Loading aliases failed with {e!r}')
|
||||||
|
|
||||||
|
@property
|
||||||
|
@contextmanager
|
||||||
|
def userdata_lock(self) -> LockedJSONData:
|
||||||
|
"""Wrapper around the lock to automatically update user data when it is released"""
|
||||||
|
with LockedJSONData(os.path.join(self.path, 'user.json')) as lock:
|
||||||
|
try:
|
||||||
|
yield lock
|
||||||
|
finally:
|
||||||
|
self._user_data = lock.data
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def userdata(self):
|
def userdata(self):
|
||||||
if self._user_data is not None:
|
if self._user_data is not None:
|
||||||
return self._user_data
|
return self._user_data
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._user_data = json.load(open(os.path.join(self.path, 'user.json')))
|
with self.userdata_lock as locked:
|
||||||
return self._user_data
|
return locked.data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.debug(f'Failed to load user data: {e!r}')
|
self.log.debug(f'Failed to load user data: {e!r}')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@userdata.setter
|
@userdata.setter
|
||||||
def userdata(self, userdata):
|
def userdata(self, userdata):
|
||||||
if userdata is None:
|
raise NotImplementedError('The setter has been removed, use the locked userdata instead.')
|
||||||
raise ValueError('Userdata is none!')
|
|
||||||
|
|
||||||
self._user_data = userdata
|
|
||||||
json.dump(userdata, open(os.path.join(self.path, 'user.json'), 'w'),
|
|
||||||
indent=2, sort_keys=True)
|
|
||||||
|
|
||||||
def invalidate_userdata(self):
|
def invalidate_userdata(self):
|
||||||
self._user_data = None
|
with self.userdata_lock as lock:
|
||||||
if os.path.exists(os.path.join(self.path, 'user.json')):
|
lock.clear()
|
||||||
os.remove(os.path.join(self.path, 'user.json'))
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def entitlements(self):
|
def entitlements(self):
|
||||||
|
@ -217,8 +240,7 @@ class LGDLFS:
|
||||||
f.write(manifest_data)
|
f.write(manifest_data)
|
||||||
|
|
||||||
def get_game_meta(self, app_name):
|
def get_game_meta(self, app_name):
|
||||||
_meta = self._game_metadata.get(app_name, None)
|
if _meta := self._game_metadata.get(app_name, None):
|
||||||
if _meta:
|
|
||||||
return Game.from_json(_meta)
|
return Game.from_json(_meta)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -229,14 +251,14 @@ class LGDLFS:
|
||||||
json.dump(json_meta, open(meta_file, 'w'), indent=2, sort_keys=True)
|
json.dump(json_meta, open(meta_file, 'w'), indent=2, sort_keys=True)
|
||||||
|
|
||||||
def delete_game_meta(self, app_name):
|
def delete_game_meta(self, app_name):
|
||||||
if app_name in self._game_metadata:
|
if app_name not in self._game_metadata:
|
||||||
del self._game_metadata[app_name]
|
|
||||||
meta_file = os.path.join(self.path, 'metadata', f'{app_name}.json')
|
|
||||||
if os.path.exists(meta_file):
|
|
||||||
os.remove(meta_file)
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Game {app_name} does not exist in metadata DB!')
|
raise ValueError(f'Game {app_name} does not exist in metadata DB!')
|
||||||
|
|
||||||
|
del self._game_metadata[app_name]
|
||||||
|
meta_file = os.path.join(self.path, 'metadata', f'{app_name}.json')
|
||||||
|
if os.path.exists(meta_file):
|
||||||
|
os.remove(meta_file)
|
||||||
|
|
||||||
def get_game_app_names(self):
|
def get_game_app_names(self):
|
||||||
return sorted(self._game_metadata.keys())
|
return sorted(self._game_metadata.keys())
|
||||||
|
|
||||||
|
@ -260,7 +282,16 @@ class LGDLFS:
|
||||||
self.log.warning(f'Failed to delete file "{f}": {e!r}')
|
self.log.warning(f'Failed to delete file "{f}": {e!r}')
|
||||||
|
|
||||||
def clean_manifests(self, in_use):
|
def clean_manifests(self, in_use):
|
||||||
in_use_files = set(f'{clean_filename(f"{app_name}_{version}")}.manifest' for app_name, version in in_use)
|
in_use_files = {
|
||||||
|
f'{clean_filename(f"{app_name}_{version}")}.manifest'
|
||||||
|
for app_name, version, _ in in_use
|
||||||
|
}
|
||||||
|
|
||||||
|
in_use_files |= {
|
||||||
|
f'{clean_filename(f"{app_name}_{platform}_{version}")}.manifest'
|
||||||
|
for app_name, version, platform in in_use
|
||||||
|
}
|
||||||
|
|
||||||
for f in os.listdir(os.path.join(self.path, 'manifests')):
|
for f in os.listdir(os.path.join(self.path, 'manifests')):
|
||||||
if f not in in_use_files:
|
if f not in in_use_files:
|
||||||
try:
|
try:
|
||||||
|
@ -268,6 +299,27 @@ class LGDLFS:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(f'Failed to delete file "{f}": {e!r}')
|
self.log.warning(f'Failed to delete file "{f}": {e!r}')
|
||||||
|
|
||||||
|
def lock_installed(self) -> bool:
|
||||||
|
"""
|
||||||
|
Locks the install data. We do not care about releasing this lock.
|
||||||
|
If it is acquired by a Legendary instance it should own the lock until it exits.
|
||||||
|
Some operations such as egl sync may be simply skipped if a lock cannot be acquired
|
||||||
|
"""
|
||||||
|
if self._installed_lock.is_locked:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._installed_lock.acquire(blocking=False)
|
||||||
|
# reload data in case it has been updated elsewhere
|
||||||
|
try:
|
||||||
|
self._installed = json.load(open(os.path.join(self.path, 'installed.json')))
|
||||||
|
except Exception as e:
|
||||||
|
self.log.debug(f'Failed to load installed game data: {e!r}')
|
||||||
|
|
||||||
|
return True
|
||||||
|
except TimeoutError:
|
||||||
|
return False
|
||||||
|
|
||||||
def get_installed_game(self, app_name):
|
def get_installed_game(self, app_name):
|
||||||
if self._installed is None:
|
if self._installed is None:
|
||||||
try:
|
try:
|
||||||
|
@ -276,8 +328,7 @@ class LGDLFS:
|
||||||
self.log.debug(f'Failed to load installed game data: {e!r}')
|
self.log.debug(f'Failed to load installed game data: {e!r}')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
game_json = self._installed.get(app_name, None)
|
if game_json := self._installed.get(app_name, None):
|
||||||
if game_json:
|
|
||||||
return InstalledGame.from_json(game_json)
|
return InstalledGame.from_json(game_json)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -354,7 +405,7 @@ class LGDLFS:
|
||||||
try:
|
try:
|
||||||
return json.load(open(os.path.join(self.path, 'tmp', f'{app_name}.json')))
|
return json.load(open(os.path.join(self.path, 'tmp', f'{app_name}.json')))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.debug(f'Failed to load cached update data: {e!r}')
|
self.log.debug(f'Failed to load cached SDL data: {e!r}')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set_cached_sdl_data(self, app_name, sdl_version, sdl_data):
|
def set_cached_sdl_data(self, app_name, sdl_version, sdl_data):
|
||||||
|
@ -364,6 +415,47 @@ class LGDLFS:
|
||||||
open(os.path.join(self.path, 'tmp', f'{app_name}.json'), 'w'),
|
open(os.path.join(self.path, 'tmp', f'{app_name}.json'), 'w'),
|
||||||
indent=2, sort_keys=True)
|
indent=2, sort_keys=True)
|
||||||
|
|
||||||
|
def get_cached_overlay_version(self):
|
||||||
|
if self._overlay_update_info:
|
||||||
|
return self._overlay_update_info
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._overlay_update_info = json.load(open(
|
||||||
|
os.path.join(self.path, 'overlay_version.json')))
|
||||||
|
except Exception as e:
|
||||||
|
self.log.debug(f'Failed to load cached Overlay update data: {e!r}')
|
||||||
|
self._overlay_update_info = dict(last_update=0, data=None)
|
||||||
|
|
||||||
|
return self._overlay_update_info
|
||||||
|
|
||||||
|
def set_cached_overlay_version(self, version_data):
|
||||||
|
self._overlay_update_info = dict(last_update=time(), data=version_data)
|
||||||
|
json.dump(self._overlay_update_info,
|
||||||
|
open(os.path.join(self.path, 'overlay_version.json'), 'w'),
|
||||||
|
indent=2, sort_keys=True)
|
||||||
|
|
||||||
|
def get_overlay_install_info(self):
|
||||||
|
if not self._overlay_install_info:
|
||||||
|
try:
|
||||||
|
data = json.load(open(os.path.join(self.path, 'overlay_install.json')))
|
||||||
|
self._overlay_install_info = InstalledGame.from_json(data)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.debug(f'Failed to load overlay install data: {e!r}')
|
||||||
|
|
||||||
|
return self._overlay_install_info
|
||||||
|
|
||||||
|
def set_overlay_install_info(self, igame: InstalledGame):
|
||||||
|
self._overlay_install_info = igame
|
||||||
|
json.dump(vars(igame), open(os.path.join(self.path, 'overlay_install.json'), 'w'),
|
||||||
|
indent=2, sort_keys=True)
|
||||||
|
|
||||||
|
def remove_overlay_install_info(self):
|
||||||
|
try:
|
||||||
|
self._overlay_install_info = None
|
||||||
|
os.remove(os.path.join(self.path, 'overlay_install.json'))
|
||||||
|
except Exception as e:
|
||||||
|
self.log.debug(f'Failed to delete overlay install data: {e!r}')
|
||||||
|
|
||||||
def generate_aliases(self):
|
def generate_aliases(self):
|
||||||
self.log.debug('Generating list of aliases...')
|
self.log.debug('Generating list of aliases...')
|
||||||
|
|
||||||
|
@ -393,9 +485,7 @@ class LGDLFS:
|
||||||
|
|
||||||
def serialise_sets(obj):
|
def serialise_sets(obj):
|
||||||
"""Turn sets into sorted lists for storage"""
|
"""Turn sets into sorted lists for storage"""
|
||||||
if isinstance(obj, set):
|
return sorted(obj) if isinstance(obj, set) else obj
|
||||||
return sorted(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
json.dump(alias_map, open(os.path.join(self.path, 'aliases.json'), 'w', newline='\n'),
|
json.dump(alias_map, open(os.path.join(self.path, 'aliases.json'), 'w', newline='\n'),
|
||||||
indent=2, sort_keys=True, default=serialise_sets)
|
indent=2, sort_keys=True, default=serialise_sets)
|
||||||
|
|
|
@ -3,11 +3,16 @@
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from sys import stdout
|
||||||
|
from time import perf_counter
|
||||||
from typing import List, Iterator
|
from typing import List, Iterator
|
||||||
|
|
||||||
|
from filelock import FileLock
|
||||||
|
|
||||||
from legendary.models.game import VerifyResult
|
from legendary.models.game import VerifyResult
|
||||||
|
|
||||||
logger = logging.getLogger('LFS Utils')
|
logger = logging.getLogger('LFS Utils')
|
||||||
|
@ -75,14 +80,16 @@ def delete_filelist(path: str, filenames: List[str],
|
||||||
return no_error
|
return no_error
|
||||||
|
|
||||||
|
|
||||||
def validate_files(base_path: str, filelist: List[tuple], hash_type='sha1') -> Iterator[tuple]:
|
def validate_files(base_path: str, filelist: List[tuple], hash_type='sha1',
|
||||||
|
large_file_threshold=1024 * 1024 * 512) -> Iterator[tuple]:
|
||||||
"""
|
"""
|
||||||
Validates the files in filelist in path against the provided hashes
|
Validates the files in filelist in path against the provided hashes
|
||||||
|
|
||||||
:param base_path: path in which the files are located
|
:param base_path: path in which the files are located
|
||||||
:param filelist: list of tuples in format (path, hash [hex])
|
:param filelist: list of tuples in format (path, hash [hex])
|
||||||
:param hash_type: (optional) type of hash, default is sha1
|
:param hash_type: (optional) type of hash, default is sha1
|
||||||
:return: list of files that failed hash check
|
:param large_file_threshold: (optional) threshold for large files, default is 512 MiB
|
||||||
|
:return: yields tuples in format (VerifyResult, path, hash [hex], bytes read)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not filelist:
|
if not filelist:
|
||||||
|
@ -96,23 +103,51 @@ def validate_files(base_path: str, filelist: List[tuple], hash_type='sha1') -> I
|
||||||
# logger.debug(f'Checking "{file_path}"...')
|
# logger.debug(f'Checking "{file_path}"...')
|
||||||
|
|
||||||
if not os.path.exists(full_path):
|
if not os.path.exists(full_path):
|
||||||
yield VerifyResult.FILE_MISSING, file_path, ''
|
yield VerifyResult.FILE_MISSING, file_path, '', 0
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
show_progress = False
|
||||||
|
interval = 0
|
||||||
|
speed = 0.0
|
||||||
|
start_time = 0.0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_size = os.path.getsize(full_path)
|
||||||
|
if _size > large_file_threshold:
|
||||||
|
# enable progress indicator and go to new line
|
||||||
|
stdout.write('\n')
|
||||||
|
show_progress = True
|
||||||
|
interval = (_size / (1024 * 1024)) // 100
|
||||||
|
start_time = perf_counter()
|
||||||
|
|
||||||
with open(full_path, 'rb') as f:
|
with open(full_path, 'rb') as f:
|
||||||
real_file_hash = hashlib.new(hash_type)
|
real_file_hash = hashlib.new(hash_type)
|
||||||
|
i = 0
|
||||||
while chunk := f.read(1024*1024):
|
while chunk := f.read(1024*1024):
|
||||||
real_file_hash.update(chunk)
|
real_file_hash.update(chunk)
|
||||||
|
if show_progress and i % interval == 0:
|
||||||
|
pos = f.tell()
|
||||||
|
perc = (pos / _size) * 100
|
||||||
|
speed = pos / 1024 / 1024 / (perf_counter() - start_time)
|
||||||
|
stdout.write(f'\r=> Verifying large file "{file_path}": {perc:.0f}% '
|
||||||
|
f'({pos / 1024 / 1024:.1f}/{_size / 1024 / 1024:.1f} MiB) '
|
||||||
|
f'[{speed:.1f} MiB/s]\t')
|
||||||
|
stdout.flush()
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if show_progress:
|
||||||
|
stdout.write(f'\r=> Verifying large file "{file_path}": 100% '
|
||||||
|
f'({_size / 1024 / 1024:.1f}/{_size / 1024 / 1024:.1f} MiB) '
|
||||||
|
f'[{speed:.1f} MiB/s]\t\n')
|
||||||
|
|
||||||
result_hash = real_file_hash.hexdigest()
|
result_hash = real_file_hash.hexdigest()
|
||||||
if file_hash != result_hash:
|
if file_hash != result_hash:
|
||||||
yield VerifyResult.HASH_MISMATCH, file_path, result_hash
|
yield VerifyResult.HASH_MISMATCH, file_path, result_hash, f.tell()
|
||||||
else:
|
else:
|
||||||
yield VerifyResult.HASH_MATCH, file_path, result_hash
|
yield VerifyResult.HASH_MATCH, file_path, result_hash, f.tell()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.fatal(f'Could not verify "{file_path}"; opening failed with: {e!r}')
|
logger.fatal(f'Could not verify "{file_path}"; opening failed with: {e!r}')
|
||||||
yield VerifyResult.OTHER_ERROR, file_path, ''
|
yield VerifyResult.OTHER_ERROR, file_path, '', 0
|
||||||
|
|
||||||
|
|
||||||
def clean_filename(filename):
|
def clean_filename(filename):
|
||||||
|
@ -121,3 +156,45 @@ def clean_filename(filename):
|
||||||
|
|
||||||
def get_dir_size(path):
|
def get_dir_size(path):
|
||||||
return sum(f.stat().st_size for f in Path(path).glob('**/*') if f.is_file())
|
return sum(f.stat().st_size for f in Path(path).glob('**/*') if f.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
class LockedJSONData(FileLock):
|
||||||
|
def __init__(self, lock_file: str):
|
||||||
|
super().__init__(lock_file + '.lock')
|
||||||
|
|
||||||
|
self._file_path = lock_file
|
||||||
|
self._data = None
|
||||||
|
self._initial_data = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
super().__enter__()
|
||||||
|
|
||||||
|
if os.path.exists(self._file_path):
|
||||||
|
with open(self._file_path, 'r', encoding='utf-8') as f:
|
||||||
|
self._data = json.load(f)
|
||||||
|
self._initial_data = self._data
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
super().__exit__(exc_type, exc_val, exc_tb)
|
||||||
|
|
||||||
|
if self._data != self._initial_data:
|
||||||
|
if self._data is not None:
|
||||||
|
with open(self._file_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self._data, f, indent=2, sort_keys=True)
|
||||||
|
else:
|
||||||
|
if os.path.exists(self._file_path):
|
||||||
|
os.remove(self._file_path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
@data.setter
|
||||||
|
def data(self, new_data):
|
||||||
|
if new_data is None:
|
||||||
|
raise ValueError('Invalid new data, use clear() explicitly to reset file data')
|
||||||
|
self._data = new_data
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self._data = None
|
96
legendary/lfs/windows_helpers.py
Normal file
96
legendary/lfs/windows_helpers.py
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import logging
|
||||||
|
import winreg
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
_logger = logging.getLogger('WindowsHelpers')
|
||||||
|
|
||||||
|
HKEY_CURRENT_USER = winreg.HKEY_CURRENT_USER
|
||||||
|
HKEY_LOCAL_MACHINE = winreg.HKEY_LOCAL_MACHINE
|
||||||
|
TYPE_STRING = winreg.REG_SZ
|
||||||
|
TYPE_DWORD = winreg.REG_DWORD
|
||||||
|
|
||||||
|
|
||||||
|
def query_registry_value(hive, key, value):
|
||||||
|
ret = None
|
||||||
|
try:
|
||||||
|
k = winreg.OpenKey(hive, key, reserved=0, access=winreg.KEY_READ)
|
||||||
|
except FileNotFoundError:
|
||||||
|
_logger.debug(f'Registry key "{key}" not found')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
ret, _ = winreg.QueryValueEx(k, value)
|
||||||
|
except FileNotFoundError:
|
||||||
|
_logger.debug(f'Registry value "{key}":"{value}" not found')
|
||||||
|
winreg.CloseKey(k)
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def list_registry_values(hive, key, use_32bit_view=False):
|
||||||
|
ret = []
|
||||||
|
|
||||||
|
access = winreg.KEY_READ
|
||||||
|
if use_32bit_view:
|
||||||
|
access |= winreg.KEY_WOW64_32KEY
|
||||||
|
|
||||||
|
try:
|
||||||
|
k = winreg.OpenKey(hive, key, reserved=0, access=access)
|
||||||
|
except FileNotFoundError:
|
||||||
|
_logger.debug(f'Registry key "{key}" not found')
|
||||||
|
else:
|
||||||
|
idx = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ret.append(winreg.EnumValue(k, idx))
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def remove_registry_value(hive, key, value, use_32bit_view=False):
|
||||||
|
access = winreg.KEY_ALL_ACCESS
|
||||||
|
if use_32bit_view:
|
||||||
|
access |= winreg.KEY_WOW64_32KEY
|
||||||
|
|
||||||
|
try:
|
||||||
|
k = winreg.OpenKey(hive, key, reserved=0, access=access)
|
||||||
|
except FileNotFoundError:
|
||||||
|
_logger.debug(f'Registry key "{key}" not found')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
winreg.DeleteValue(k, value)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug(f'Deleting "{key}":"{value}" failed with {repr(e)}')
|
||||||
|
winreg.CloseKey(k)
|
||||||
|
|
||||||
|
|
||||||
|
def set_registry_value(hive, key, value, data, reg_type=winreg.REG_SZ, use_32bit_view=False):
|
||||||
|
access = winreg.KEY_ALL_ACCESS
|
||||||
|
if use_32bit_view:
|
||||||
|
access |= winreg.KEY_WOW64_32KEY
|
||||||
|
|
||||||
|
try:
|
||||||
|
k = winreg.CreateKeyEx(hive, key, reserved=0, access=access)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug(f'Failed creating/opening registry key "{key}" with {repr(e)}')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
winreg.SetValueEx(k, value, 0, reg_type, data)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug(f'Setting "{key}":"{value}" to "{data}" failed with {repr(e)}')
|
||||||
|
winreg.CloseKey(k)
|
||||||
|
|
||||||
|
|
||||||
|
def double_clicked() -> bool:
|
||||||
|
# Thanks https://stackoverflow.com/a/55476145
|
||||||
|
|
||||||
|
# Load kernel32.dll
|
||||||
|
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||||
|
# Create an array to store the processes in. This doesn't actually need to
|
||||||
|
# be large enough to store the whole process list since GetConsoleProcessList()
|
||||||
|
# just returns the number of processes if the array is too small.
|
||||||
|
process_array = (ctypes.c_uint * 1)()
|
||||||
|
num_processes = kernel32.GetConsoleProcessList(process_array, 1)
|
||||||
|
return num_processes < 3
|
|
@ -6,7 +6,7 @@ logger = logging.getLogger('WineHelpers')
|
||||||
|
|
||||||
|
|
||||||
def read_registry(wine_pfx):
|
def read_registry(wine_pfx):
|
||||||
reg = configparser.ConfigParser(comment_prefixes=(';', '#', '/', 'WINE'), allow_no_value=True)
|
reg = configparser.ConfigParser(comment_prefixes=(';', '#', '/', 'WINE'), allow_no_value=True, strict=False)
|
||||||
reg.optionxform = str
|
reg.optionxform = str
|
||||||
reg.read(os.path.join(wine_pfx, 'user.reg'))
|
reg.read(os.path.join(wine_pfx, 'user.reg'))
|
||||||
return reg
|
return reg
|
||||||
|
@ -20,6 +20,37 @@ def get_shell_folders(registry, wine_pfx):
|
||||||
return folders
|
return folders
|
||||||
|
|
||||||
|
|
||||||
|
def case_insensitive_file_search(path: str) -> str:
|
||||||
|
"""
|
||||||
|
Similar to case_insensitive_path_search: Finds a file case-insensitively
|
||||||
|
Note that this *does* work on Windows, although it's rather pointless
|
||||||
|
"""
|
||||||
|
path_parts = os.path.normpath(path).split(os.sep)
|
||||||
|
# If path_parts[0] is empty, we're on Unix and thus start searching at /
|
||||||
|
if not path_parts[0]:
|
||||||
|
path_parts[0] = '/'
|
||||||
|
|
||||||
|
computed_path = path_parts[0]
|
||||||
|
for part in path_parts[1:]:
|
||||||
|
# If the computed directory does not exist, add all remaining parts as-is to at least return a valid path
|
||||||
|
# at the end
|
||||||
|
if not os.path.exists(computed_path):
|
||||||
|
computed_path = os.path.join(computed_path, part)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# First try to find an exact match
|
||||||
|
actual_file_or_dirname = part if os.path.exists(os.path.join(computed_path, part)) else None
|
||||||
|
|
||||||
|
# If there is no case-sensitive match, find a case-insensitive one
|
||||||
|
if not actual_file_or_dirname:
|
||||||
|
actual_file_or_dirname = next((
|
||||||
|
x for x in os.listdir(computed_path)
|
||||||
|
if x.lower() == part.lower()
|
||||||
|
), part)
|
||||||
|
computed_path = os.path.join(computed_path, actual_file_or_dirname)
|
||||||
|
return computed_path
|
||||||
|
|
||||||
|
|
||||||
def case_insensitive_path_search(path):
|
def case_insensitive_path_search(path):
|
||||||
"""
|
"""
|
||||||
Attempts to find a path case-insensitively
|
Attempts to find a path case-insensitively
|
||||||
|
@ -52,11 +83,11 @@ def case_insensitive_path_search(path):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# once we stop finding parts break
|
# once we stop finding parts break
|
||||||
still_remaining = remaining_parts[idx-1:]
|
still_remaining = remaining_parts[idx:]
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.debug(f'New longest path: {longest_path}')
|
logger.debug(f'New longest path: {longest_path}')
|
||||||
logger.debug(f'Still unresolved: {still_remaining}')
|
logger.debug(f'Still unresolved: {still_remaining}')
|
||||||
final_path = os.path.join(*longest_path, *still_remaining)
|
final_path = os.path.join(*longest_path, *still_remaining)
|
||||||
logger.debug('Final path:', final_path)
|
logger.debug(f'Final path: {final_path}')
|
||||||
return os.path.realpath(final_path)
|
return os.path.realpath(final_path)
|
|
@ -113,10 +113,7 @@ class Chunk:
|
||||||
return _chunk
|
return _chunk
|
||||||
|
|
||||||
def write(self, fp=None, compress=True):
|
def write(self, fp=None, compress=True):
|
||||||
if not fp:
|
bio = fp or BytesIO()
|
||||||
bio = BytesIO()
|
|
||||||
else:
|
|
||||||
bio = fp
|
|
||||||
|
|
||||||
self.uncompressed_size = self.compressed_size = len(self.data)
|
self.uncompressed_size = self.compressed_size = len(self.data)
|
||||||
if compress or self.compressed:
|
if compress or self.compressed:
|
||||||
|
@ -143,7 +140,4 @@ class Chunk:
|
||||||
# finally, add the data
|
# finally, add the data
|
||||||
bio.write(self._data)
|
bio.write(self._data)
|
||||||
|
|
||||||
if not fp:
|
return bio.tell() if fp else bio.getvalue()
|
||||||
return bio.getvalue()
|
|
||||||
else:
|
|
||||||
return bio.tell()
|
|
||||||
|
|
|
@ -127,6 +127,7 @@ class AnalysisResult:
|
||||||
dl_size: int = 0
|
dl_size: int = 0
|
||||||
uncompressed_dl_size: int = 0
|
uncompressed_dl_size: int = 0
|
||||||
install_size: int = 0
|
install_size: int = 0
|
||||||
|
disk_space_delta: int = 0
|
||||||
reuse_size: int = 0
|
reuse_size: int = 0
|
||||||
biggest_file_size: int = 0
|
biggest_file_size: int = 0
|
||||||
unchanged_size: int = 0
|
unchanged_size: int = 0
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from distutils.util import strtobool
|
|
||||||
|
|
||||||
from legendary.models.game import InstalledGame, Game
|
from legendary.models.game import InstalledGame, Game
|
||||||
|
from legendary.utils.cli import strtobool
|
||||||
|
|
||||||
|
|
||||||
_template = {
|
_template = {
|
||||||
|
@ -145,9 +145,9 @@ class EGLManifest:
|
||||||
tmp.executable = igame.executable
|
tmp.executable = igame.executable
|
||||||
tmp.main_game_appname = game.app_name # todo for DLC support this needs to be the base game
|
tmp.main_game_appname = game.app_name # todo for DLC support this needs to be the base game
|
||||||
tmp.app_folder_name = game.metadata.get('customAttributes', {}).get('FolderName', {}).get('value', '')
|
tmp.app_folder_name = game.metadata.get('customAttributes', {}).get('FolderName', {}).get('value', '')
|
||||||
tmp.manifest_location = igame.install_path + '/.egstore'
|
tmp.manifest_location = f'{igame.install_path}/.egstore'
|
||||||
tmp.ownership_token = igame.requires_ot
|
tmp.ownership_token = igame.requires_ot
|
||||||
tmp.staging_location = igame.install_path + '/.egstore/bps'
|
tmp.staging_location = f'{igame.install_path}/.egstore/bps'
|
||||||
tmp.can_run_offline = igame.can_run_offline
|
tmp.can_run_offline = igame.can_run_offline
|
||||||
tmp.is_incomplete_install = False
|
tmp.is_incomplete_install = False
|
||||||
tmp.needs_validation = igame.needs_verification
|
tmp.needs_validation = igame.needs_verification
|
||||||
|
|
|
@ -18,6 +18,7 @@ class GameAsset:
|
||||||
label_name: str = ''
|
label_name: str = ''
|
||||||
namespace: str = ''
|
namespace: str = ''
|
||||||
metadata: Dict = field(default_factory=dict)
|
metadata: Dict = field(default_factory=dict)
|
||||||
|
sidecar_rev: int = 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_egs_json(cls, json):
|
def from_egs_json(cls, json):
|
||||||
|
@ -29,6 +30,7 @@ class GameAsset:
|
||||||
tmp.label_name = json.get('labelName', '')
|
tmp.label_name = json.get('labelName', '')
|
||||||
tmp.namespace = json.get('namespace', '')
|
tmp.namespace = json.get('namespace', '')
|
||||||
tmp.metadata = json.get('metadata', {})
|
tmp.metadata = json.get('metadata', {})
|
||||||
|
tmp.sidecar_rev = json.get('sidecarRvn', 0)
|
||||||
return tmp
|
return tmp
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -41,9 +43,26 @@ class GameAsset:
|
||||||
tmp.label_name = json.get('label_name', '')
|
tmp.label_name = json.get('label_name', '')
|
||||||
tmp.namespace = json.get('namespace', '')
|
tmp.namespace = json.get('namespace', '')
|
||||||
tmp.metadata = json.get('metadata', {})
|
tmp.metadata = json.get('metadata', {})
|
||||||
|
tmp.sidecar_rev = json.get('sidecar_rev', 0)
|
||||||
return tmp
|
return tmp
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Sidecar:
|
||||||
|
"""
|
||||||
|
App sidecar data
|
||||||
|
"""
|
||||||
|
config: Dict
|
||||||
|
rev: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, json):
|
||||||
|
return cls(
|
||||||
|
config=json.get('config', {}),
|
||||||
|
rev=json.get('rev', 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Game:
|
class Game:
|
||||||
"""
|
"""
|
||||||
|
@ -55,6 +74,7 @@ class Game:
|
||||||
asset_infos: Dict[str, GameAsset] = field(default_factory=dict)
|
asset_infos: Dict[str, GameAsset] = field(default_factory=dict)
|
||||||
base_urls: List[str] = field(default_factory=list)
|
base_urls: List[str] = field(default_factory=list)
|
||||||
metadata: Dict = field(default_factory=dict)
|
metadata: Dict = field(default_factory=dict)
|
||||||
|
sidecar: Optional[Sidecar] = None
|
||||||
|
|
||||||
def app_version(self, platform='Windows'):
|
def app_version(self, platform='Windows'):
|
||||||
if platform not in self.asset_infos:
|
if platform not in self.asset_infos:
|
||||||
|
@ -66,7 +86,11 @@ class Game:
|
||||||
return self.metadata and 'mainGameItem' in self.metadata
|
return self.metadata and 'mainGameItem' in self.metadata
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def third_party_store(self):
|
def is_origin_game(self) -> bool:
|
||||||
|
return self.third_party_store and self.third_party_store.lower() in ['origin', 'the ea app']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def third_party_store(self) -> Optional[str]:
|
||||||
if not self.metadata:
|
if not self.metadata:
|
||||||
return None
|
return None
|
||||||
return self.metadata.get('customAttributes', {}).get('ThirdPartyManagedApp', {}).get('value', None)
|
return self.metadata.get('customAttributes', {}).get('ThirdPartyManagedApp', {}).get('value', None)
|
||||||
|
@ -91,6 +115,18 @@ class Game:
|
||||||
def supports_mac_cloud_saves(self):
|
def supports_mac_cloud_saves(self):
|
||||||
return self.metadata and (self.metadata.get('customAttributes', {}).get('CloudSaveFolder_MAC') is not None)
|
return self.metadata and (self.metadata.get('customAttributes', {}).get('CloudSaveFolder_MAC') is not None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def additional_command_line(self):
|
||||||
|
if not self.metadata:
|
||||||
|
return None
|
||||||
|
return self.metadata.get('customAttributes', {}).get('AdditionalCommandLine', {}).get('value', None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_launchable_addon(self):
|
||||||
|
if not self.metadata:
|
||||||
|
return False
|
||||||
|
return any(m['path'] == 'addons/launchable' for m in self.metadata.get('categories', []))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def catalog_item_id(self):
|
def catalog_item_id(self):
|
||||||
if not self.metadata:
|
if not self.metadata:
|
||||||
|
@ -116,6 +152,9 @@ class Game:
|
||||||
# Migrate old asset_info to new asset_infos
|
# Migrate old asset_info to new asset_infos
|
||||||
tmp.asset_infos['Windows'] = GameAsset.from_json(json.get('asset_info', dict()))
|
tmp.asset_infos['Windows'] = GameAsset.from_json(json.get('asset_info', dict()))
|
||||||
|
|
||||||
|
if sidecar := json.get('sidecar', None):
|
||||||
|
tmp.sidecar = Sidecar.from_json(sidecar)
|
||||||
|
|
||||||
tmp.base_urls = json.get('base_urls', list())
|
tmp.base_urls = json.get('base_urls', list())
|
||||||
return tmp
|
return tmp
|
||||||
|
|
||||||
|
@ -123,8 +162,9 @@ class Game:
|
||||||
def __dict__(self):
|
def __dict__(self):
|
||||||
"""This is just here so asset_infos gets turned into a dict as well"""
|
"""This is just here so asset_infos gets turned into a dict as well"""
|
||||||
assets_dictified = {k: v.__dict__ for k, v in self.asset_infos.items()}
|
assets_dictified = {k: v.__dict__ for k, v in self.asset_infos.items()}
|
||||||
|
sidecar_dictified = self.sidecar.__dict__ if self.sidecar else None
|
||||||
return dict(metadata=self.metadata, asset_infos=assets_dictified, app_name=self.app_name,
|
return dict(metadata=self.metadata, asset_infos=assets_dictified, app_name=self.app_name,
|
||||||
app_title=self.app_title, base_urls=self.base_urls)
|
app_title=self.app_title, base_urls=self.base_urls, sidecar=sidecar_dictified)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -149,6 +189,7 @@ class InstalledGame:
|
||||||
needs_verification: bool = False
|
needs_verification: bool = False
|
||||||
platform: str = 'Windows'
|
platform: str = 'Windows'
|
||||||
prereq_info: Optional[Dict] = None
|
prereq_info: Optional[Dict] = None
|
||||||
|
uninstaller: Optional[Dict] = None
|
||||||
requires_ot: bool = False
|
requires_ot: bool = False
|
||||||
save_path: Optional[str] = None
|
save_path: Optional[str] = None
|
||||||
|
|
||||||
|
@ -165,6 +206,7 @@ class InstalledGame:
|
||||||
tmp.executable = json.get('executable', '')
|
tmp.executable = json.get('executable', '')
|
||||||
tmp.launch_parameters = json.get('launch_parameters', '')
|
tmp.launch_parameters = json.get('launch_parameters', '')
|
||||||
tmp.prereq_info = json.get('prereq_info', None)
|
tmp.prereq_info = json.get('prereq_info', None)
|
||||||
|
tmp.uninstaller = json.get('uninstaller', None)
|
||||||
|
|
||||||
tmp.can_run_offline = json.get('can_run_offline', False)
|
tmp.can_run_offline = json.get('can_run_offline', False)
|
||||||
tmp.requires_ot = json.get('requires_ot', False)
|
tmp.requires_ot = json.get('requires_ot', False)
|
||||||
|
@ -222,3 +264,5 @@ class LaunchParameters:
|
||||||
# user and environment supplied options
|
# user and environment supplied options
|
||||||
user_parameters: list = field(default_factory=list)
|
user_parameters: list = field(default_factory=list)
|
||||||
environment: dict = field(default_factory=dict)
|
environment: dict = field(default_factory=dict)
|
||||||
|
pre_launch_command: str = ''
|
||||||
|
pre_launch_wait: bool = False
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
@ -7,6 +9,7 @@ import zlib
|
||||||
|
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger('Manifest')
|
logger = logging.getLogger('Manifest')
|
||||||
|
|
||||||
|
@ -61,7 +64,7 @@ def get_chunk_dir(version):
|
||||||
|
|
||||||
class Manifest:
|
class Manifest:
|
||||||
header_magic = 0x44BEC00C
|
header_magic = 0x44BEC00C
|
||||||
serialisation_version = 18
|
default_serialisation_version = 17
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.header_size = 41
|
self.header_size = 41
|
||||||
|
@ -73,10 +76,10 @@ class Manifest:
|
||||||
self.data = b''
|
self.data = b''
|
||||||
|
|
||||||
# remainder
|
# remainder
|
||||||
self.meta = None
|
self.meta: Optional[ManifestMeta] = None
|
||||||
self.chunk_data_list = None
|
self.chunk_data_list: Optional[CDL] = None
|
||||||
self.file_manifest_list = None
|
self.file_manifest_list: Optional[FML] = None
|
||||||
self.custom_fields = None
|
self.custom_fields: Optional[CustomFields] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def compressed(self):
|
def compressed(self):
|
||||||
|
@ -92,8 +95,7 @@ class Manifest:
|
||||||
_m.file_manifest_list = FML.read(_tmp)
|
_m.file_manifest_list = FML.read(_tmp)
|
||||||
_m.custom_fields = CustomFields.read(_tmp)
|
_m.custom_fields = CustomFields.read(_tmp)
|
||||||
|
|
||||||
unhandled_data = _tmp.read()
|
if unhandled_data := _tmp.read():
|
||||||
if unhandled_data:
|
|
||||||
logger.warning(f'Did not read {len(unhandled_data)} remaining bytes in manifest! '
|
logger.warning(f'Did not read {len(unhandled_data)} remaining bytes in manifest! '
|
||||||
f'This may not be a problem.')
|
f'This may not be a problem.')
|
||||||
|
|
||||||
|
@ -138,6 +140,26 @@ class Manifest:
|
||||||
def write(self, fp=None, compress=True):
|
def write(self, fp=None, compress=True):
|
||||||
body_bio = BytesIO()
|
body_bio = BytesIO()
|
||||||
|
|
||||||
|
# set serialisation version based on enabled features or original version
|
||||||
|
target_version = max(self.default_serialisation_version, self.meta.feature_level)
|
||||||
|
if self.meta.data_version == 2:
|
||||||
|
target_version = max(21, target_version)
|
||||||
|
elif self.file_manifest_list.version == 2:
|
||||||
|
target_version = max(20, target_version)
|
||||||
|
elif self.file_manifest_list.version == 1:
|
||||||
|
target_version = max(19, target_version)
|
||||||
|
elif self.meta.data_version == 1:
|
||||||
|
target_version = max(18, target_version)
|
||||||
|
|
||||||
|
# Downgrade manifest if unknown newer version
|
||||||
|
if target_version > 21:
|
||||||
|
logger.warning(f'Trying to serialise an unknown target version: {target_version},'
|
||||||
|
f'clamping to 21.')
|
||||||
|
target_version = 21
|
||||||
|
|
||||||
|
# Ensure metadata will be correct
|
||||||
|
self.meta.feature_level = target_version
|
||||||
|
|
||||||
self.meta.write(body_bio)
|
self.meta.write(body_bio)
|
||||||
self.chunk_data_list.write(body_bio)
|
self.chunk_data_list.write(body_bio)
|
||||||
self.file_manifest_list.write(body_bio)
|
self.file_manifest_list.write(body_bio)
|
||||||
|
@ -152,10 +174,7 @@ class Manifest:
|
||||||
self.data = zlib.compress(self.data)
|
self.data = zlib.compress(self.data)
|
||||||
self.size_compressed = len(self.data)
|
self.size_compressed = len(self.data)
|
||||||
|
|
||||||
if not fp:
|
bio = fp or BytesIO()
|
||||||
bio = BytesIO()
|
|
||||||
else:
|
|
||||||
bio = fp
|
|
||||||
|
|
||||||
bio.write(struct.pack('<I', self.header_magic))
|
bio.write(struct.pack('<I', self.header_magic))
|
||||||
bio.write(struct.pack('<I', self.header_size))
|
bio.write(struct.pack('<I', self.header_size))
|
||||||
|
@ -163,18 +182,50 @@ class Manifest:
|
||||||
bio.write(struct.pack('<I', self.size_compressed))
|
bio.write(struct.pack('<I', self.size_compressed))
|
||||||
bio.write(self.sha_hash)
|
bio.write(self.sha_hash)
|
||||||
bio.write(struct.pack('B', self.stored_as))
|
bio.write(struct.pack('B', self.stored_as))
|
||||||
bio.write(struct.pack('<I', self.serialisation_version))
|
bio.write(struct.pack('<I', target_version))
|
||||||
bio.write(self.data)
|
bio.write(self.data)
|
||||||
|
|
||||||
if not fp:
|
return bio.tell() if fp else bio.getvalue()
|
||||||
return bio.getvalue()
|
|
||||||
else:
|
def apply_delta_manifest(self, delta_manifest: Manifest):
|
||||||
return bio.tell()
|
added = set()
|
||||||
|
# overwrite file elements with the ones from the delta manifest
|
||||||
|
for idx, file_elem in enumerate(self.file_manifest_list.elements):
|
||||||
|
try:
|
||||||
|
delta_file = delta_manifest.file_manifest_list.get_file_by_path(file_elem.filename)
|
||||||
|
self.file_manifest_list.elements[idx] = delta_file
|
||||||
|
added.add(delta_file.filename)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# add other files that may be missing
|
||||||
|
for delta_file in delta_manifest.file_manifest_list.elements:
|
||||||
|
if delta_file.filename not in added:
|
||||||
|
self.file_manifest_list.elements.append(delta_file)
|
||||||
|
# update count and clear map
|
||||||
|
self.file_manifest_list.count = len(self.file_manifest_list.elements)
|
||||||
|
self.file_manifest_list._path_map = None
|
||||||
|
|
||||||
|
# ensure guid map exists (0 will most likely yield no result, so ignore ValueError)
|
||||||
|
try:
|
||||||
|
self.chunk_data_list.get_chunk_by_guid(0)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# add new chunks from delta manifest to main manifest and again clear maps and update count
|
||||||
|
existing_chunk_guids = self.chunk_data_list._guid_int_map.keys()
|
||||||
|
|
||||||
|
for chunk in delta_manifest.chunk_data_list.elements:
|
||||||
|
if chunk.guid_num not in existing_chunk_guids:
|
||||||
|
self.chunk_data_list.elements.append(chunk)
|
||||||
|
|
||||||
|
self.chunk_data_list.count = len(self.chunk_data_list.elements)
|
||||||
|
self.chunk_data_list._guid_map = None
|
||||||
|
self.chunk_data_list._guid_int_map = None
|
||||||
|
self.chunk_data_list._path_map = None
|
||||||
|
|
||||||
|
|
||||||
class ManifestMeta:
|
class ManifestMeta:
|
||||||
serialisation_version = 0
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.meta_size = 0
|
self.meta_size = 0
|
||||||
self.data_version = 0
|
self.data_version = 0
|
||||||
|
@ -189,6 +240,8 @@ class ManifestMeta:
|
||||||
self.prereq_name = ''
|
self.prereq_name = ''
|
||||||
self.prereq_path = ''
|
self.prereq_path = ''
|
||||||
self.prereq_args = ''
|
self.prereq_args = ''
|
||||||
|
self.uninstall_action_path = ''
|
||||||
|
self.uninstall_action_args = ''
|
||||||
# this build id is used for something called "delta file" which I guess I'll have to implement eventually
|
# this build id is used for something called "delta file" which I guess I'll have to implement eventually
|
||||||
self._build_id = ''
|
self._build_id = ''
|
||||||
|
|
||||||
|
@ -226,16 +279,20 @@ class ManifestMeta:
|
||||||
|
|
||||||
# This is a list though I've never seen more than one entry
|
# This is a list though I've never seen more than one entry
|
||||||
entries = struct.unpack('<I', bio.read(4))[0]
|
entries = struct.unpack('<I', bio.read(4))[0]
|
||||||
for i in range(entries):
|
for _ in range(entries):
|
||||||
_meta.prereq_ids.append(read_fstring(bio))
|
_meta.prereq_ids.append(read_fstring(bio))
|
||||||
|
|
||||||
_meta.prereq_name = read_fstring(bio)
|
_meta.prereq_name = read_fstring(bio)
|
||||||
_meta.prereq_path = read_fstring(bio)
|
_meta.prereq_path = read_fstring(bio)
|
||||||
_meta.prereq_args = read_fstring(bio)
|
_meta.prereq_args = read_fstring(bio)
|
||||||
|
|
||||||
# apparently there's a newer version that actually stores *a* build id.
|
# Manifest version 18 with data version >= 1 stores build ID
|
||||||
if _meta.data_version > 0:
|
if _meta.data_version >= 1:
|
||||||
_meta._build_id = read_fstring(bio)
|
_meta._build_id = read_fstring(bio)
|
||||||
|
# Manifest version 21 with data version >= 2 stores uninstall commands
|
||||||
|
if _meta.data_version >= 2:
|
||||||
|
_meta.uninstall_action_path = read_fstring(bio)
|
||||||
|
_meta.uninstall_action_args = read_fstring(bio)
|
||||||
|
|
||||||
if (size_read := bio.tell()) != _meta.meta_size:
|
if (size_read := bio.tell()) != _meta.meta_size:
|
||||||
logger.warning(f'Did not read entire manifest metadata! Version: {_meta.data_version}, '
|
logger.warning(f'Did not read entire manifest metadata! Version: {_meta.data_version}, '
|
||||||
|
@ -250,7 +307,7 @@ class ManifestMeta:
|
||||||
meta_start = bio.tell()
|
meta_start = bio.tell()
|
||||||
|
|
||||||
bio.write(struct.pack('<I', 0)) # placeholder size
|
bio.write(struct.pack('<I', 0)) # placeholder size
|
||||||
bio.write(struct.pack('B', self.serialisation_version))
|
bio.write(struct.pack('B', self.data_version))
|
||||||
bio.write(struct.pack('<I', self.feature_level))
|
bio.write(struct.pack('<I', self.feature_level))
|
||||||
bio.write(struct.pack('B', self.is_file_data))
|
bio.write(struct.pack('B', self.is_file_data))
|
||||||
bio.write(struct.pack('<I', self.app_id))
|
bio.write(struct.pack('<I', self.app_id))
|
||||||
|
@ -267,8 +324,11 @@ class ManifestMeta:
|
||||||
write_fstring(bio, self.prereq_path)
|
write_fstring(bio, self.prereq_path)
|
||||||
write_fstring(bio, self.prereq_args)
|
write_fstring(bio, self.prereq_args)
|
||||||
|
|
||||||
if self.data_version > 0:
|
if self.data_version >= 1:
|
||||||
write_fstring(bio, self.build_id)
|
write_fstring(bio, self.build_id)
|
||||||
|
if self.data_version >= 2:
|
||||||
|
write_fstring(bio, self.uninstall_action_path)
|
||||||
|
write_fstring(bio, self.uninstall_action_args)
|
||||||
|
|
||||||
meta_end = bio.tell()
|
meta_end = bio.tell()
|
||||||
bio.seek(meta_start)
|
bio.seek(meta_start)
|
||||||
|
@ -277,8 +337,6 @@ class ManifestMeta:
|
||||||
|
|
||||||
|
|
||||||
class CDL:
|
class CDL:
|
||||||
serialisation_version = 0
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.version = 0
|
self.version = 0
|
||||||
self.size = 0
|
self.size = 0
|
||||||
|
@ -348,7 +406,7 @@ class CDL:
|
||||||
|
|
||||||
# the way this data is stored is rather odd, maybe there's a nicer way to write this...
|
# the way this data is stored is rather odd, maybe there's a nicer way to write this...
|
||||||
|
|
||||||
for i in range(_cdl.count):
|
for _ in range(_cdl.count):
|
||||||
_cdl.elements.append(ChunkInfo(manifest_version=manifest_version))
|
_cdl.elements.append(ChunkInfo(manifest_version=manifest_version))
|
||||||
|
|
||||||
# guid, doesn't seem to be a standard like UUID but is fairly straightfoward, 4 bytes, 128 bit.
|
# guid, doesn't seem to be a standard like UUID but is fairly straightfoward, 4 bytes, 128 bit.
|
||||||
|
@ -387,7 +445,7 @@ class CDL:
|
||||||
def write(self, bio):
|
def write(self, bio):
|
||||||
cdl_start = bio.tell()
|
cdl_start = bio.tell()
|
||||||
bio.write(struct.pack('<I', 0)) # placeholder size
|
bio.write(struct.pack('<I', 0)) # placeholder size
|
||||||
bio.write(struct.pack('B', self.serialisation_version))
|
bio.write(struct.pack('B', self.version))
|
||||||
bio.write(struct.pack('<I', len(self.elements)))
|
bio.write(struct.pack('<I', len(self.elements)))
|
||||||
|
|
||||||
for chunk in self.elements:
|
for chunk in self.elements:
|
||||||
|
@ -466,8 +524,6 @@ class ChunkInfo:
|
||||||
|
|
||||||
|
|
||||||
class FML:
|
class FML:
|
||||||
serialisation_version = 0
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.version = 0
|
self.version = 0
|
||||||
self.size = 0
|
self.size = 0
|
||||||
|
@ -495,7 +551,7 @@ class FML:
|
||||||
_fml.version = struct.unpack('B', bio.read(1))[0]
|
_fml.version = struct.unpack('B', bio.read(1))[0]
|
||||||
_fml.count = struct.unpack('<I', bio.read(4))[0]
|
_fml.count = struct.unpack('<I', bio.read(4))[0]
|
||||||
|
|
||||||
for i in range(_fml.count):
|
for _ in range(_fml.count):
|
||||||
_fml.elements.append(FileManifest())
|
_fml.elements.append(FileManifest())
|
||||||
|
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
|
@ -516,14 +572,14 @@ class FML:
|
||||||
# install tags, no idea what they do, I've only seen them in the Fortnite manifest
|
# install tags, no idea what they do, I've only seen them in the Fortnite manifest
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
_elem = struct.unpack('<I', bio.read(4))[0]
|
_elem = struct.unpack('<I', bio.read(4))[0]
|
||||||
for i in range(_elem):
|
for _ in range(_elem):
|
||||||
fm.install_tags.append(read_fstring(bio))
|
fm.install_tags.append(read_fstring(bio))
|
||||||
|
|
||||||
# Each file is made up of "Chunk Parts" that can be spread across the "chunk stream"
|
# Each file is made up of "Chunk Parts" that can be spread across the "chunk stream"
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
_elem = struct.unpack('<I', bio.read(4))[0]
|
_elem = struct.unpack('<I', bio.read(4))[0]
|
||||||
_offset = 0
|
_offset = 0
|
||||||
for i in range(_elem):
|
for _ in range(_elem):
|
||||||
chunkp = ChunkPart()
|
chunkp = ChunkPart()
|
||||||
_start = bio.tell()
|
_start = bio.tell()
|
||||||
_size = struct.unpack('<I', bio.read(4))[0]
|
_size = struct.unpack('<I', bio.read(4))[0]
|
||||||
|
@ -537,7 +593,7 @@ class FML:
|
||||||
logger.warning(f'Did not read {diff} bytes from chunk part!')
|
logger.warning(f'Did not read {diff} bytes from chunk part!')
|
||||||
bio.seek(diff)
|
bio.seek(diff)
|
||||||
|
|
||||||
# MD5 hash + MIME type
|
# MD5 hash + MIME type (Manifest feature level 19)
|
||||||
if _fml.version >= 1:
|
if _fml.version >= 1:
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
_has_md5 = struct.unpack('<I', bio.read(4))[0]
|
_has_md5 = struct.unpack('<I', bio.read(4))[0]
|
||||||
|
@ -547,7 +603,7 @@ class FML:
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
fm.mime_type = read_fstring(bio)
|
fm.mime_type = read_fstring(bio)
|
||||||
|
|
||||||
# SHA256 hash
|
# SHA256 hash (Manifest feature level 20)
|
||||||
if _fml.version >= 2:
|
if _fml.version >= 2:
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
fm.hash_sha256 = bio.read(32)
|
fm.hash_sha256 = bio.read(32)
|
||||||
|
@ -568,7 +624,7 @@ class FML:
|
||||||
def write(self, bio):
|
def write(self, bio):
|
||||||
fml_start = bio.tell()
|
fml_start = bio.tell()
|
||||||
bio.write(struct.pack('<I', 0)) # placeholder size
|
bio.write(struct.pack('<I', 0)) # placeholder size
|
||||||
bio.write(struct.pack('B', self.serialisation_version))
|
bio.write(struct.pack('B', self.version))
|
||||||
bio.write(struct.pack('<I', len(self.elements)))
|
bio.write(struct.pack('<I', len(self.elements)))
|
||||||
|
|
||||||
for fm in self.elements:
|
for fm in self.elements:
|
||||||
|
@ -594,6 +650,20 @@ class FML:
|
||||||
bio.write(struct.pack('<I', cp.offset))
|
bio.write(struct.pack('<I', cp.offset))
|
||||||
bio.write(struct.pack('<I', cp.size))
|
bio.write(struct.pack('<I', cp.size))
|
||||||
|
|
||||||
|
if self.version >= 1:
|
||||||
|
for fm in self.elements:
|
||||||
|
has_md5 = 1 if fm.hash_md5 else 0
|
||||||
|
bio.write(struct.pack('<I', has_md5))
|
||||||
|
if has_md5:
|
||||||
|
bio.write(fm.hash_md5)
|
||||||
|
|
||||||
|
for fm in self.elements:
|
||||||
|
write_fstring(bio, fm.mime_type)
|
||||||
|
|
||||||
|
if self.version >= 2:
|
||||||
|
for fm in self.elements:
|
||||||
|
bio.write(fm.hash_sha256)
|
||||||
|
|
||||||
fml_end = bio.tell()
|
fml_end = bio.tell()
|
||||||
bio.seek(fml_start)
|
bio.seek(fml_start)
|
||||||
bio.write(struct.pack('<I', fml_end - fml_start))
|
bio.write(struct.pack('<I', fml_end - fml_start))
|
||||||
|
@ -637,6 +707,7 @@ class FileManifest:
|
||||||
_cp.append('[...]')
|
_cp.append('[...]')
|
||||||
cp_repr = ', '.join(_cp)
|
cp_repr = ', '.join(_cp)
|
||||||
|
|
||||||
|
# ToDo add MD5, MIME, SHA256 if those ever become relevant
|
||||||
return '<FileManifest (filename="{}", symlink_target="{}", hash={}, flags={}, ' \
|
return '<FileManifest (filename="{}", symlink_target="{}", hash={}, flags={}, ' \
|
||||||
'install_tags=[{}], chunk_parts=[{}], file_size={})>'.format(
|
'install_tags=[{}], chunk_parts=[{}], file_size={})>'.format(
|
||||||
self.filename, self.symlink_target, self.hash.hex(), self.flags,
|
self.filename, self.symlink_target, self.hash.hex(), self.flags,
|
||||||
|
@ -673,8 +744,6 @@ class ChunkPart:
|
||||||
|
|
||||||
|
|
||||||
class CustomFields:
|
class CustomFields:
|
||||||
serialisation_version = 0
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.size = 0
|
self.size = 0
|
||||||
self.version = 0
|
self.version = 0
|
||||||
|
@ -709,15 +778,8 @@ class CustomFields:
|
||||||
_cf.version = struct.unpack('B', bio.read(1))[0]
|
_cf.version = struct.unpack('B', bio.read(1))[0]
|
||||||
_cf.count = struct.unpack('<I', bio.read(4))[0]
|
_cf.count = struct.unpack('<I', bio.read(4))[0]
|
||||||
|
|
||||||
_keys = []
|
_keys = [read_fstring(bio) for _ in range(_cf.count)]
|
||||||
_values = []
|
_values = [read_fstring(bio) for _ in range(_cf.count)]
|
||||||
|
|
||||||
for i in range(_cf.count):
|
|
||||||
_keys.append(read_fstring(bio))
|
|
||||||
|
|
||||||
for i in range(_cf.count):
|
|
||||||
_values.append(read_fstring(bio))
|
|
||||||
|
|
||||||
_cf._dict = dict(zip(_keys, _values))
|
_cf._dict = dict(zip(_keys, _values))
|
||||||
|
|
||||||
if (size_read := bio.tell() - cf_start) != _cf.size:
|
if (size_read := bio.tell() - cf_start) != _cf.size:
|
||||||
|
@ -732,7 +794,7 @@ class CustomFields:
|
||||||
def write(self, bio):
|
def write(self, bio):
|
||||||
cf_start = bio.tell()
|
cf_start = bio.tell()
|
||||||
bio.write(struct.pack('<I', 0)) # placeholder size
|
bio.write(struct.pack('<I', 0)) # placeholder size
|
||||||
bio.write(struct.pack('B', self.serialisation_version))
|
bio.write(struct.pack('B', self.version))
|
||||||
bio.write(struct.pack('<I', len(self._dict)))
|
bio.write(struct.pack('<I', len(self._dict)))
|
||||||
|
|
||||||
for key in self.keys():
|
for key in self.keys():
|
||||||
|
@ -766,8 +828,7 @@ class ManifestComparison:
|
||||||
old_files = {fm.filename: fm.hash for fm in old_manifest.file_manifest_list.elements}
|
old_files = {fm.filename: fm.hash for fm in old_manifest.file_manifest_list.elements}
|
||||||
|
|
||||||
for fm in manifest.file_manifest_list.elements:
|
for fm in manifest.file_manifest_list.elements:
|
||||||
old_file_hash = old_files.pop(fm.filename, None)
|
if old_file_hash := old_files.pop(fm.filename, None):
|
||||||
if old_file_hash:
|
|
||||||
if fm.hash == old_file_hash:
|
if fm.hash == old_file_hash:
|
||||||
comp.unchanged.add(fm.filename)
|
comp.unchanged.add(fm.filename)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
def get_boolean_choice(prompt, default=True):
|
def get_boolean_choice(prompt, default=True):
|
||||||
if default:
|
yn = 'Y/n' if default else 'y/N'
|
||||||
yn = 'Y/n'
|
|
||||||
else:
|
|
||||||
yn = 'y/N'
|
|
||||||
|
|
||||||
choice = input(f'{prompt} [{yn}]: ')
|
choice = input(f'{prompt} [{yn}]: ')
|
||||||
if not choice:
|
if not choice:
|
||||||
|
@ -13,6 +10,39 @@ def get_boolean_choice(prompt, default=True):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_int_choice(prompt, default=None, min_choice=None, max_choice=None, return_on_invalid=False):
|
||||||
|
if default is not None:
|
||||||
|
prompt = f'{prompt} [{default}]: '
|
||||||
|
else:
|
||||||
|
prompt = f'{prompt}: '
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if inp := input(prompt):
|
||||||
|
choice = int(inp)
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
except ValueError:
|
||||||
|
if return_on_invalid:
|
||||||
|
return None
|
||||||
|
return_on_invalid = True
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if min_choice is not None and choice < min_choice:
|
||||||
|
print(f'Number must be greater than {min_choice}')
|
||||||
|
if return_on_invalid:
|
||||||
|
return None
|
||||||
|
return_on_invalid = True
|
||||||
|
continue
|
||||||
|
if max_choice is not None and choice > max_choice:
|
||||||
|
print(f'Number must be less than {max_choice}')
|
||||||
|
if return_on_invalid:
|
||||||
|
return None
|
||||||
|
return_on_invalid = True
|
||||||
|
continue
|
||||||
|
return choice
|
||||||
|
|
||||||
|
|
||||||
def sdl_prompt(sdl_data, title):
|
def sdl_prompt(sdl_data, title):
|
||||||
tags = ['']
|
tags = ['']
|
||||||
if '__required' in sdl_data:
|
if '__required' in sdl_data:
|
||||||
|
@ -28,7 +58,7 @@ def sdl_prompt(sdl_data, title):
|
||||||
examples = ', '.join([g for g in sdl_data.keys() if g != '__required'][:2])
|
examples = ', '.join([g for g in sdl_data.keys() if g != '__required'][:2])
|
||||||
print(f'Please enter tags of pack(s) to install (space/comma-separated, e.g. "{examples}")')
|
print(f'Please enter tags of pack(s) to install (space/comma-separated, e.g. "{examples}")')
|
||||||
print('Leave blank to use defaults (only required data will be downloaded).')
|
print('Leave blank to use defaults (only required data will be downloaded).')
|
||||||
choices = input(f'Additional packs [Enter to confirm]: ')
|
choices = input('Additional packs [Enter to confirm]: ')
|
||||||
if not choices:
|
if not choices:
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
@ -40,3 +70,22 @@ def sdl_prompt(sdl_data, title):
|
||||||
print('Invalid tag:', c)
|
print('Invalid tag:', c)
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def strtobool(val):
|
||||||
|
"""Convert a string representation of truth to true (1) or false (0).
|
||||||
|
|
||||||
|
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
|
||||||
|
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
|
||||||
|
'val' is anything else.
|
||||||
|
|
||||||
|
Copied from python standard library as distutils.util.strtobool is deprecated.
|
||||||
|
"""
|
||||||
|
val = val.lower()
|
||||||
|
if val in ('y', 'yes', 't', 'true', 'on', '1'):
|
||||||
|
return 1
|
||||||
|
elif val in ('n', 'no', 'f', 'false', 'off', '0'):
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
raise ValueError("invalid truth value %r" % (val,))
|
||||||
|
|
||||||
|
|
|
@ -1,29 +1,28 @@
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
# reference: https://gist.github.com/sampsyo/471779#gistcomment-2886157
|
|
||||||
|
|
||||||
|
|
||||||
class AliasedSubParsersAction(argparse._SubParsersAction):
|
|
||||||
class _AliasedPseudoAction(argparse.Action):
|
|
||||||
def __init__(self, name, aliases, help):
|
|
||||||
dest = name
|
|
||||||
if aliases:
|
|
||||||
dest += ' (%s)' % ','.join(aliases)
|
|
||||||
sup = super(AliasedSubParsersAction._AliasedPseudoAction, self)
|
|
||||||
sup.__init__(option_strings=[], dest=dest, help=help)
|
|
||||||
|
|
||||||
|
class HiddenAliasSubparsersAction(argparse._SubParsersAction):
|
||||||
def add_parser(self, name, **kwargs):
|
def add_parser(self, name, **kwargs):
|
||||||
aliases = kwargs.pop('aliases', [])
|
# set prog from the existing prefix
|
||||||
parser = super(AliasedSubParsersAction, self).add_parser(name, **kwargs)
|
if kwargs.get('prog') is None:
|
||||||
|
kwargs['prog'] = f'{self._prog_prefix} {name}'
|
||||||
|
|
||||||
# Make the aliases work.
|
aliases = kwargs.pop('aliases', ())
|
||||||
for alias in aliases:
|
hide_aliases = kwargs.pop('hide_aliases', False)
|
||||||
self._name_parser_map[alias] = parser
|
|
||||||
# Make the help text reflect them, first removing old help entry.
|
# create a pseudo-action to hold the choice help
|
||||||
if 'help' in kwargs:
|
if 'help' in kwargs:
|
||||||
help = kwargs.pop('help')
|
help = kwargs.pop('help')
|
||||||
self._choices_actions.pop()
|
_aliases = None if hide_aliases else aliases
|
||||||
pseudo_action = self._AliasedPseudoAction(name, aliases, help)
|
choice_action = self._ChoicesPseudoAction(name, _aliases, help)
|
||||||
self._choices_actions.append(pseudo_action)
|
self._choices_actions.append(choice_action)
|
||||||
|
|
||||||
|
# create the parser and add it to the map
|
||||||
|
parser = self._parser_class(**kwargs)
|
||||||
|
self._name_parser_map[name] = parser
|
||||||
|
|
||||||
|
# make parser available under aliases also
|
||||||
|
for alias in aliases:
|
||||||
|
self._name_parser_map[alias] = parser
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
|
|
||||||
|
from sys import platform
|
||||||
|
|
||||||
# games where the download order optimizations are enabled by default
|
# games where the download order optimizations are enabled by default
|
||||||
# a set() of versions can be specified, empty set means all versions.
|
# a set() of versions can be specified, empty set means all versions.
|
||||||
_optimize_default = {
|
_optimize_default = {
|
||||||
|
@ -11,6 +13,15 @@ _optimize_default = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Some games use launchers that don't work with Legendary, these are overriden here
|
||||||
|
_exe_overrides = {
|
||||||
|
'kinglet': {
|
||||||
|
'darwin': 'Base/Binaries/Win64EOS/CivilizationVI.exe',
|
||||||
|
'linux': 'Base/Binaries/Win64EOS/CivilizationVI.exe',
|
||||||
|
'win32': 'LaunchPad/LaunchPad.exe'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_opt_enabled(app_name, version):
|
def is_opt_enabled(app_name, version):
|
||||||
if (versions := _optimize_default.get(app_name.lower())) is not None:
|
if (versions := _optimize_default.get(app_name.lower())) is not None:
|
||||||
|
@ -19,8 +30,15 @@ def is_opt_enabled(app_name, version):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_exe_override(app_name):
|
||||||
|
return _exe_overrides.get(app_name.lower(), {}).get(platform, None)
|
||||||
|
|
||||||
|
|
||||||
def update_workarounds(api_data):
|
def update_workarounds(api_data):
|
||||||
if 'reorder_optimization' in api_data:
|
if 'reorder_optimization' in api_data:
|
||||||
_optimize_default.clear()
|
_optimize_default.clear()
|
||||||
_optimize_default.update(api_data['reorder_optimization'])
|
_optimize_default.update(api_data['reorder_optimization'])
|
||||||
|
if 'executable_override' in api_data:
|
||||||
|
_exe_overrides.clear()
|
||||||
|
_exe_overrides.update(api_data['executable_override'])
|
||||||
|
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
from legendary.models.manifest import Manifest
|
|
||||||
|
|
||||||
|
|
||||||
def combine_manifests(base_manifest: Manifest, delta_manifest: Manifest):
|
|
||||||
added = set()
|
|
||||||
# overwrite file elements with the ones from the delta manifest
|
|
||||||
for idx, file_elem in enumerate(base_manifest.file_manifest_list.elements):
|
|
||||||
try:
|
|
||||||
delta_file = delta_manifest.file_manifest_list.get_file_by_path(file_elem.filename)
|
|
||||||
base_manifest.file_manifest_list.elements[idx] = delta_file
|
|
||||||
added.add(delta_file.filename)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# add other files that may be missing
|
|
||||||
for delta_file in delta_manifest.file_manifest_list.elements:
|
|
||||||
if delta_file.filename not in added:
|
|
||||||
base_manifest.file_manifest_list.elements.append(delta_file)
|
|
||||||
# update count and clear map
|
|
||||||
base_manifest.file_manifest_list.count = len(base_manifest.file_manifest_list.elements)
|
|
||||||
base_manifest.file_manifest_list._path_map = None
|
|
||||||
|
|
||||||
# ensure guid map exists
|
|
||||||
try:
|
|
||||||
base_manifest.chunk_data_list.get_chunk_by_guid(0)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# add new chunks from delta manifest to main manifest and again clear maps and update count
|
|
||||||
existing_chunk_guids = base_manifest.chunk_data_list._guid_int_map.keys()
|
|
||||||
|
|
||||||
for chunk in delta_manifest.chunk_data_list.elements:
|
|
||||||
if chunk.guid_num not in existing_chunk_guids:
|
|
||||||
base_manifest.chunk_data_list.elements.append(chunk)
|
|
||||||
|
|
||||||
base_manifest.chunk_data_list.count = len(base_manifest.chunk_data_list.elements)
|
|
||||||
base_manifest.chunk_data_list._guid_map = None
|
|
||||||
base_manifest.chunk_data_list._guid_int_map = None
|
|
||||||
base_manifest.chunk_data_list._path_map = None
|
|
|
@ -22,11 +22,14 @@ def _filename_matches(filename, patterns):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
if pattern.endswith('/'):
|
# Pattern is a directory, just check if path starts with it
|
||||||
# pat is a directory, check if path starts with it
|
if pattern.endswith('/') and filename.startswith(pattern):
|
||||||
if filename.startswith(pattern):
|
return True
|
||||||
return True
|
# Check if pattern is a suffix of filename
|
||||||
elif fnmatch(filename, pattern):
|
if filename.endswith(pattern):
|
||||||
|
return True
|
||||||
|
# Check if pattern with wildcards ('*') matches
|
||||||
|
if fnmatch(filename, pattern):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
@ -167,3 +170,21 @@ class SaveGameHelper:
|
||||||
|
|
||||||
# return dict with created files for uploading/whatever
|
# return dict with created files for uploading/whatever
|
||||||
return self.files
|
return self.files
|
||||||
|
|
||||||
|
def get_deletion_list(self, save_folder, include_filter=None, exclude_filter=None):
|
||||||
|
files = []
|
||||||
|
for _dir, _, _files in os.walk(save_folder):
|
||||||
|
for _file in _files:
|
||||||
|
_file_path = os.path.join(_dir, _file)
|
||||||
|
_file_path_rel = os.path.relpath(_file_path, save_folder).replace('\\', '/')
|
||||||
|
|
||||||
|
if include_filter and not _filename_matches(_file_path_rel, include_filter):
|
||||||
|
self.log.debug(f'Excluding "{_file_path_rel}" (does not match include filter)')
|
||||||
|
continue
|
||||||
|
elif exclude_filter and _filename_matches(_file_path_rel, exclude_filter):
|
||||||
|
self.log.debug(f'Excluding "{_file_path_rel}" (does match exclude filter)')
|
||||||
|
continue
|
||||||
|
|
||||||
|
files.append(_file_path_rel)
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
|
@ -22,7 +22,7 @@ except Exception as e:
|
||||||
|
|
||||||
login_url = 'https://www.epicgames.com/id/login'
|
login_url = 'https://www.epicgames.com/id/login'
|
||||||
sid_url = 'https://www.epicgames.com/id/api/redirect?'
|
sid_url = 'https://www.epicgames.com/id/api/redirect?'
|
||||||
logout_url = 'https://www.epicgames.com/id/logout?productName=epic-games&redirectUrl=' + login_url
|
logout_url = f'https://www.epicgames.com/id/logout?productName=epic-games&redirectUrl={login_url}'
|
||||||
goodbye_url = 'https://legendary.gl/goodbye'
|
goodbye_url = 'https://legendary.gl/goodbye'
|
||||||
window_js = '''
|
window_js = '''
|
||||||
window.ue = {
|
window.ue = {
|
||||||
|
@ -31,11 +31,7 @@ window.ue = {
|
||||||
registersignincompletecallback: pywebview.api.trigger_sid_exchange
|
registersignincompletecallback: pywebview.api.trigger_sid_exchange
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
launchexternalurl: pywebview.api.open_url_external,
|
launchexternalurl: pywebview.api.open_url_external
|
||||||
// not required, just needs to be non-null
|
|
||||||
auth: {
|
|
||||||
completeLogin: pywebview.api.nop
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
'''
|
'''
|
||||||
|
@ -74,9 +70,11 @@ class MockLauncher:
|
||||||
if self.inject_js:
|
if self.inject_js:
|
||||||
self.window.evaluate_js(window_js)
|
self.window.evaluate_js(window_js)
|
||||||
|
|
||||||
if 'logout' in url:
|
if 'logout' in url and self.callback_sid:
|
||||||
# prepare to close browser after logout redirect
|
# prepare to close browser after logout redirect
|
||||||
self.destroy_on_load = True
|
self.destroy_on_load = True
|
||||||
|
elif 'logout' in url:
|
||||||
|
self.inject_js = True
|
||||||
|
|
||||||
def nop(self, *args, **kwargs):
|
def nop(self, *args, **kwargs):
|
||||||
return
|
return
|
||||||
|
@ -91,21 +89,22 @@ class MockLauncher:
|
||||||
# skip logging out on those platforms and directly use the exchange code we're given.
|
# skip logging out on those platforms and directly use the exchange code we're given.
|
||||||
# On windows we have to do a little dance with the SID to create a session that
|
# On windows we have to do a little dance with the SID to create a session that
|
||||||
# remains valid after logging out in the embedded browser.
|
# remains valid after logging out in the embedded browser.
|
||||||
if self.window.gui.renderer in ('gtkwebkit2', 'qtwebengine', 'qtwebkit'):
|
# Update: Epic broke SID login, we'll also do this on Windows now
|
||||||
self.destroy_on_load = True
|
# if self.window.gui.renderer in ('gtkwebkit2', 'qtwebengine', 'qtwebkit'):
|
||||||
try:
|
self.destroy_on_load = True
|
||||||
self.callback_result = self.callback_code(exchange_code)
|
try:
|
||||||
except Exception as e:
|
self.callback_result = self.callback_code(exchange_code)
|
||||||
logger.error(f'Logging in via exchange-code failed with {e!r}')
|
except Exception as e:
|
||||||
finally:
|
logger.error(f'Logging in via exchange-code failed with {e!r}')
|
||||||
# We cannot destroy the browser from here,
|
finally:
|
||||||
# so we'll load a small goodbye site first.
|
# We cannot destroy the browser from here,
|
||||||
self.window.load_url(goodbye_url)
|
# so we'll load a small goodbye site first.
|
||||||
|
self.window.load_url(goodbye_url)
|
||||||
|
|
||||||
def trigger_sid_exchange(self, *args, **kwargs):
|
def trigger_sid_exchange(self, *args, **kwargs):
|
||||||
# check if code-based login hasn't already set the destroy flag
|
# check if code-based login hasn't already set the destroy flag
|
||||||
if not self.destroy_on_load:
|
if not self.destroy_on_load:
|
||||||
logger.debug(f'Injecting SID JS')
|
logger.debug('Injecting SID JS')
|
||||||
# inject JS to get SID API response and call our API
|
# inject JS to get SID API response and call our API
|
||||||
self.window.evaluate_js(get_sid_js)
|
self.window.evaluate_js(get_sid_js)
|
||||||
|
|
||||||
|
@ -125,22 +124,32 @@ class MockLauncher:
|
||||||
self.window.load_url(logout_url)
|
self.window.load_url(logout_url)
|
||||||
|
|
||||||
|
|
||||||
def do_webview_login(callback_sid=None, callback_code=None):
|
def do_webview_login(callback_sid=None, callback_code=None, user_agent=None):
|
||||||
api = MockLauncher(callback_sid=callback_sid, callback_code=callback_code)
|
api = MockLauncher(callback_sid=callback_sid, callback_code=callback_code)
|
||||||
|
url = login_url
|
||||||
|
|
||||||
|
if os.name == 'nt':
|
||||||
|
# On Windows we open the logout URL first to invalidate the current cookies (if any).
|
||||||
|
# Additionally, we have to disable JS injection for the first load, as otherwise the user
|
||||||
|
# will get an error for some reason.
|
||||||
|
url = logout_url
|
||||||
|
api.inject_js = False
|
||||||
|
|
||||||
logger.info('Opening Epic Games login window...')
|
logger.info('Opening Epic Games login window...')
|
||||||
|
# Open logout URL first to remove existing cookies, then redirect to login.
|
||||||
window = webview.create_window(f'Legendary {__version__} - Epic Games Account Login',
|
window = webview.create_window(f'Legendary {__version__} - Epic Games Account Login',
|
||||||
url=login_url, width=768, height=1024, js_api=api)
|
url=url, width=768, height=1024, js_api=api)
|
||||||
api.window = window
|
api.window = window
|
||||||
window.loaded += api.on_loaded
|
window.events.loaded += api.on_loaded
|
||||||
|
|
||||||
try:
|
try:
|
||||||
webview.start()
|
webview.start(user_agent=user_agent)
|
||||||
except Exception as we:
|
except Exception as we:
|
||||||
logger.error(f'Running webview failed with {we!r}. If this error persists try the manual '
|
logger.error(f'Running webview failed with {we!r}. If this error persists try the manual '
|
||||||
f'login process by adding --disable-webview to your command line.')
|
f'login process by adding --disable-webview to your command line.')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if api.callback_result is None:
|
if api.callback_result is None:
|
||||||
logger.error(f'Login aborted by user.')
|
logger.error('Login aborted by user.')
|
||||||
|
|
||||||
return api.callback_result
|
return api.callback_result
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
requests<3.0
|
requests<3.0
|
||||||
|
filelock
|
||||||
|
|
10
setup.py
10
setup.py
|
@ -8,8 +8,8 @@ from setuptools import setup
|
||||||
|
|
||||||
from legendary import __version__ as legendary_version
|
from legendary import __version__ as legendary_version
|
||||||
|
|
||||||
if sys.version_info < (3, 8):
|
if sys.version_info < (3, 9):
|
||||||
sys.exit('python 3.8 or higher is required for legendary')
|
sys.exit('python 3.9 or higher is required for legendary')
|
||||||
|
|
||||||
with open("README.md", "r") as fh:
|
with open("README.md", "r") as fh:
|
||||||
long_description_l = fh.readlines()
|
long_description_l = fh.readlines()
|
||||||
|
@ -37,7 +37,8 @@ setup(
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'requests<3.0',
|
'requests<3.0',
|
||||||
'setuptools',
|
'setuptools',
|
||||||
'wheel'
|
'wheel',
|
||||||
|
'filelock'
|
||||||
],
|
],
|
||||||
extras_require=dict(
|
extras_require=dict(
|
||||||
webview=['pywebview>=3.4'],
|
webview=['pywebview>=3.4'],
|
||||||
|
@ -47,11 +48,10 @@ setup(
|
||||||
description='Free and open-source replacement for the Epic Games Launcher application',
|
description='Free and open-source replacement for the Epic Games Launcher application',
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
python_requires='>=3.8',
|
python_requires='>=3.9',
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
|
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
|
||||||
'Programming Language :: Python',
|
'Programming Language :: Python',
|
||||||
'Programming Language :: Python :: 3.8',
|
|
||||||
'Programming Language :: Python :: 3.9',
|
'Programming Language :: Python :: 3.9',
|
||||||
'Operating System :: POSIX :: Linux',
|
'Operating System :: POSIX :: Linux',
|
||||||
'Operating System :: Microsoft',
|
'Operating System :: Microsoft',
|
||||||
|
|
Loading…
Reference in a new issue