Compare commits
No files matched your search
@@ -0,0 +1,67 @@
|
||||
name: build-appimage
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('poetry.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Get linuxdeploy
|
||||
run: |
|
||||
wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -O linuxdeploy.AppImage
|
||||
chmod +x linuxdeploy.AppImage
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install poetry
|
||||
poetry install
|
||||
- name: Build resources
|
||||
run: poetry run make runekit/_resources.py
|
||||
- name: Build AppImage
|
||||
run: LINUXDEPLOY=./linuxdeploy.AppImage make dist/RuneKit.AppImage
|
||||
|
||||
# Deploy
|
||||
- name: Bump tag
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: richardsimko/update-tag@v1
|
||||
with:
|
||||
tag_name: continuous
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: dev-drprasad/delete-tag-and-release@v0.1.3
|
||||
if: github.ref == 'refs/heads/master'
|
||||
with:
|
||||
delete_release: true
|
||||
tag_name: continuous
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Upload to CI
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: github.ref == 'refs/heads/master'
|
||||
with:
|
||||
tag_name: continuous
|
||||
prerelease: true
|
||||
name: Continuous Integration
|
||||
body: These files are automatically built with GitHub Actions from the master branch.
|
||||
files: |
|
||||
dist/RuneKit.AppImage
|
||||
dist/runekit.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Upload to CI
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
dist/RuneKit.AppImage
|
||||
dist/runekit.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -4,3 +4,7 @@ runekit/image/format.c
|
||||
runekit/image/format.*.*
|
||||
setup.py
|
||||
*.egg-info
|
||||
__pycache__
|
||||
runekit/_resources.py
|
||||
RuneKitApp.spec
|
||||
*.AppImage
|
||||
@@ -14,6 +14,22 @@ The `game` package is the only one allowed to use platform-dependent code. All o
|
||||
- game: Interact with the game window. Since accessing other application's window is not abstracted by Qt, this package is the only one allowed to be platform dependent
|
||||
- image: Image processing tools
|
||||
|
||||
### Image format
|
||||
The library has two internal image formats:
|
||||
|
||||
- numpy.ndarray of shape (height, width, 4)
|
||||
- Current standard
|
||||
- Store pixels in BGRA32 format (ARGB 8 bit per channel in little endian)
|
||||
- Should be faster as no channel swapping is needed
|
||||
- Requires copying when accessing raw buffer
|
||||
- Support OpenCV operation
|
||||
- PIL Image
|
||||
- Deprecated
|
||||
- Store pixels in RGBA32 format
|
||||
- Slower as when communicating with Alt1 we need to swap to BGRA
|
||||
- Can access raw buffer without copying
|
||||
- Easier to work with for basic operations
|
||||
|
||||
## App
|
||||
|
||||
Packages related to Alt1 app.
|
||||
@@ -25,9 +41,17 @@ Packages related to Alt1 app.
|
||||
|
||||
## UI
|
||||
|
||||
- host: Main window of RuneKit
|
||||
- host: RuneKit Qt root
|
||||
- ui: Collection of UI tools
|
||||
|
||||
## Porting
|
||||
|
||||
With this design it should be simple to port RuneKit to any platform supported by Qt by just reimplementing `game` for that platform
|
||||
|
||||
# Settings design
|
||||
- apps/
|
||||
- <b2b size 16 of manifest url> (json): App manifest
|
||||
- _folder
|
||||
- <folder name>/0 (str): App ID
|
||||
- _meta
|
||||
- isDefaultLoaded (bool): Has default apps list downloaded
|
||||
@@ -0,0 +1,44 @@
|
||||
LINUXDEPLOY ?= linuxdeploy-$(shell uname -m).AppImage
|
||||
|
||||
dev: runekit/_resources.py
|
||||
|
||||
runekit/_resources.py: resources.qrc $(wildcard runekit/**/*.js) $(wildcard runekit/**/*.png)
|
||||
pyside2-rcc $< -o $@
|
||||
|
||||
# Sdist
|
||||
|
||||
dist/runekit.tar.gz: main.py poetry.lock runekit/_resources.py $(wildcard runekit/**/*)
|
||||
poetry build -f sdist
|
||||
cd dist; cp runekit-*.tar.gz runekit.tar.gz
|
||||
|
||||
# Mac
|
||||
|
||||
dist/RuneKitApp.app: main.py poetry.lock runekit/_resources.py $(wildcard runekit/**/*)
|
||||
pyinstaller -w -n RuneKitApp \
|
||||
--exclude-module tkinter \
|
||||
-s -d noarchive \
|
||||
--osx-bundle-identifier de.cupco.runekit \
|
||||
$<
|
||||
|
||||
# AppImage
|
||||
|
||||
build/python3.9.1.AppImage:
|
||||
mkdir build || true
|
||||
wget https://github.com/niess/python-appimage/releases/download/python3.9/python3.9.1-cp39-cp39-manylinux1_x86_64.AppImage -O "$@"
|
||||
chmod +x "$@"
|
||||
|
||||
build/appdir: build/python3.9.1.AppImage
|
||||
$< --appimage-extract
|
||||
mv squashfs-root build/appdir
|
||||
|
||||
dist/RuneKit.AppImage: dist/runekit.tar.gz build/appdir deploy/runekit-appimage.sh
|
||||
build/appdir/usr/bin/python3 -m pip install dist/runekit.tar.gz
|
||||
rm $(wildcard build/appdir/*.desktop) $(wildcard build/appdir/usr/share/applications/*.desktop) $(wildcard build/appdir/usr/share/metainfo/*)
|
||||
cp deploy/RuneKit.desktop build/appdir/
|
||||
cp deploy/RuneKit.desktop build/appdir/usr/share/applications/
|
||||
cp deploy/RuneKit.appdata.xml build/appdir/usr/share/metainfo/
|
||||
cp deploy/runekit-appimage.sh build/appdir/AppRun
|
||||
$(LINUXDEPLOY) --appdir build/appdir --output appimage
|
||||
cp RuneKit-*.AppImage "$@"
|
||||
|
||||
.PHONY: dev
|
||||
@@ -4,74 +4,63 @@
|
||||
|
||||
Alt1-compatible toolbox for RuneScape 3, for Linux and macOS.
|
||||
|
||||
**Alpha quality software**
|
||||
* [Compatibility](https://github.com/whs/runekit/wiki/App-Compatibility)
|
||||
* [macOS installation guide](docs/macos-setup.md)
|
||||
|
||||
## What works
|
||||
## Installing
|
||||
|
||||
**Overlay are not supported yet**
|
||||
### Linux
|
||||
|
||||
App | Linux | macOS
|
||||
-------------------------------|-------|----------
|
||||
Clue solver (Text/Emote) | ✅ | ✅
|
||||
Clue solver (Map scroll) | ? | ?
|
||||
Clue solver (Compass) | ? | 2
|
||||
Clue solver (Scan) | ? | ?
|
||||
Clue solver (Lockbox) | ? | ?
|
||||
Clue solver (Celtic Knot) | ? | ✅
|
||||
Clue solver (Slide) | ? | ✅
|
||||
Clue solver (Towers) | ? | ?
|
||||
Example app | ✅ | ✅
|
||||
AfkWarden (Inactive) | ❌ | ✅
|
||||
AfkWarden (XP Counter) | ❌ | ❌
|
||||
AfkWarden (Chatbox) | 1 | ✅
|
||||
AfkWarden (Crafting menu) | ✅ | ✅
|
||||
AfkWarden (Buffs) | ✅ | ✅
|
||||
AfkWarden (Action bar stats) | ✅ | ✅
|
||||
AfkWarden (Dialog box) | ✅ | ✅
|
||||
AfkWarden (Target death) | ? | ?
|
||||
AfkWarden (Sheathe stance) | ? | ?
|
||||
AfkWarden (Castle Wars) | ? | ?
|
||||
AfkWarden (Fight kiln waves) | ? | ?
|
||||
AfkWarden (Target death) | ? | ?
|
||||
AfkWarden (Item drops) | ? | ?
|
||||
1. [Download RuneKit.AppImage](https://github.com/whs/runekit/releases/tag/continuous)
|
||||
2. Mark file as executable (`chmod +x`)
|
||||
3. Start the game
|
||||
4. Run `RuneKit.AppImage`.
|
||||
- On first start it will download app list
|
||||
5. Right click the tray icon and start any application
|
||||
|
||||
- 1: Can be setup but only fire once (needs inactive to work)
|
||||
- 2: Detected but cannot place mark
|
||||
### macOS
|
||||
|
||||
## Running
|
||||
See developer guide in the next section.
|
||||
|
||||
You will need to add Python in System Preferences > Security > Privacy in these sections:
|
||||
|
||||
- Accessibility
|
||||
- Screen Recording
|
||||
|
||||
Note that Python might appear as the closest macOS application (eg. your terminal emulator) instead of Python
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
[See wiki](https://github.com/whs/runekit/wiki/Troubleshooting)
|
||||
|
||||
## Developer
|
||||
|
||||
This project use [Poetry](https://python-poetry.org) as package manager.
|
||||
|
||||
Requires Poetry 1.1
|
||||
Requires Poetry 1.1.
|
||||
|
||||
```sh
|
||||
poetry install
|
||||
poetry build
|
||||
cp build/lib.*/runekit/image/*.cpython*.* runekit/image/
|
||||
# If previous fails and you're on Big Sur, try this instead
|
||||
SYSTEM_VERSION_COMPAT=1 poetry install
|
||||
|
||||
poetry run make dev
|
||||
|
||||
# If you just want to load AFKWarden
|
||||
poetry run python main.py https://runeapps.org/apps/alt1/afkscape/appconfig.json
|
||||
# If you'd like to pick what app you load
|
||||
poetry run python main.py
|
||||
```
|
||||
|
||||
WIP Instruction:
|
||||
|
||||
- Run RuneScape before starting!!
|
||||
- Linux: Game window MUST be ENTIRELY visible. (No minimize, no other window on top, including RuneKit)
|
||||
- macOS: HiDPI (Retina) support may or may not work. Use external monitor for best result
|
||||
- macOS: You will need to add Python in System Preferences > Security > Privacy in these sections:
|
||||
- Accessibility
|
||||
- Screen Recording:
|
||||
- Note that it wmight appear as the closest macOS application (eg. your terminal emulator) instead of Python
|
||||
|
||||
## Developer
|
||||
|
||||
Start with `--remote-debugging-port=9222` to enable remote debugger protocol.
|
||||
To debug, go to `chrome://inspect` on Chrome/Chromium.
|
||||
|
||||
## License
|
||||
|
||||
This protect is [No Maintenance Intended](https://unmaintained.tech/).
|
||||
This project is [No Maintenance Intended](https://unmaintained.tech/).
|
||||
It is provided as-is and may not be actively maintained. There's no support, and no promise that pull requests will be
|
||||
reviewed and merged. In other word: I wrote this for my own use and there's no point in keeping it to myself so I'm sharing it.
|
||||
However, making it a proper open source project is an ongoing work that I'm not willing to do.
|
||||
However, maintaining it as a proper open source project is an ongoing work that I don't have the bandwidth to do.
|
||||
|
||||
This project is [licensed](LICENSE) under GPLv3, and contains code from [third parties](THIRD_PARTY_LICENSE.md).
|
||||
Contains code from the Alt1 application.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
from Cython.Build import cythonize
|
||||
|
||||
# use cythonize to build the extensions
|
||||
modules = [
|
||||
"runekit/image/format.pyx",
|
||||
]
|
||||
|
||||
extensions = cythonize(modules)
|
||||
|
||||
|
||||
def build(setup_kwargs):
|
||||
"""Needed for the poetry building interface."""
|
||||
|
||||
setup_kwargs.update(
|
||||
{
|
||||
"ext_modules": extensions,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
# Alt1 API
|
||||
|
||||
- [ ] alt1.overlay
|
||||
- [ ] alt1.captureMethod
|
||||
- [ ] alt1.permission*
|
||||
- [x] alt1.lastWorldHop
|
||||
- [x] alt1.currentWorld
|
||||
- [ ] alt1.rsPing
|
||||
- [ ] alt1.rsFps
|
||||
- [ ] alt1.openInfo
|
||||
- [x] alt1.screen*
|
||||
- [x] alt1.captureInterval
|
||||
- [x] alt1.mousePosition
|
||||
- [x] alt1.rsLinked
|
||||
- [x] alt1.rsWidth, rsHeight, rsScaling
|
||||
- [x] alt1.rsActive
|
||||
- [x] alt1.userResize
|
||||
- [ ] alt1.identiyAppUrl
|
||||
- [x] alt1.openBrowser
|
||||
- [x] alt1.clearBinds
|
||||
- [ ] alt1.registerStatusDaemon
|
||||
- [ ] alt1.getStatusDaemonState
|
||||
- [x] alt1.showNotification
|
||||
- [x] alt1.closeApp
|
||||
- [x] alt1.setTooltip
|
||||
- [x] alt1.clearTooltip
|
||||
- [x] alt1.setTaskbarProgress
|
||||
- [ ] alt1.setTitleBarText
|
||||
- [x] alt1.overLayRect
|
||||
- [x] alt1.overLayText
|
||||
- [x] alt1.overLayTextEx
|
||||
- [x] alt1.overLayLine
|
||||
- [x] alt1.overLayImage
|
||||
- [x] alt1.overLayClearGroup
|
||||
- [x] alt1.overLaySetGroup
|
||||
- [x] alt1.overLayFreezeGroup
|
||||
- [x] alt1.overLayContinueGroup
|
||||
- [x] alt1.overLayRefreshGroup
|
||||
- [x] alt1.overLaySetGroupZIndex
|
||||
- [x] alt1.getRegion
|
||||
- [ ] alt1.getRegionMulti
|
||||
- [x] alt1.bindRegion
|
||||
- [ ] alt1.bindScreenRegion
|
||||
- [x] alt1.bindGetRegion
|
||||
- [ ] alt1.bindReadString
|
||||
- [ ] alt1.bindReadColorString
|
||||
- [ ] alt1.bindReadStringEx
|
||||
- [ ] alt1.bindReadRightClickString
|
||||
- [ ] alt1.bindGetPixel
|
||||
- [ ] alt1.bindFindSubImg
|
||||
|
||||
# Alt1 Events
|
||||
|
||||
- [x] alt1pressed
|
||||
- [ ] menudetected
|
||||
- [ ] permissionchanged
|
||||
- [ ] daemonrun
|
||||
- [ ] userevent
|
||||
- [x] rsfocus
|
||||
- [x] blur
|
||||
|
||||
rslinked, rsunlinked probably not going to be implemented
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>runekit</id>
|
||||
<metadata_license>GPLv3</metadata_license>
|
||||
<project_license>GPLv3</project_license>
|
||||
<name>RuneKit</name>
|
||||
<description>
|
||||
<p>Alt1-compatible toolbox for RuneScape 3</p>
|
||||
</description>
|
||||
<launchable type="desktop-id">RuneKit.desktop</launchable>
|
||||
<url type="homepage">https://github.com/whs/runekit</url>
|
||||
<provides>
|
||||
<binary>runekit</binary>
|
||||
<python3>runekit</python3>
|
||||
</provides>
|
||||
</component>
|
||||
@@ -0,0 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=RuneKit
|
||||
Exec=runekit
|
||||
Comment=Alt1-compatible toolbox for RuneScape 3
|
||||
Icon=python
|
||||
Categories=Game;
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Export APPRUN if running from an extracted image
|
||||
self="$(readlink -f -- $0)"
|
||||
here="${self%/*}"
|
||||
APPDIR="${APPDIR:-${here}}"
|
||||
|
||||
# Export SSL certificate
|
||||
export SSL_CERT_FILE="${APPDIR}/opt/_internal/certs.pem"
|
||||
export PYTHONHOME="${APPDIR}/opt/python3.9"
|
||||
|
||||
exec $APPDIR/usr/bin/runekit $@
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Used for Platypus boot
|
||||
export LC_ALL=en_US.UTF-8
|
||||
export LANG=en_US.UTF-8
|
||||
./RuneKitApp/RuneKitApp
|
||||
|
||||
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,75 @@
|
||||
# MacOS Setup (for programming novices)
|
||||
|
||||
## Packaged release
|
||||
|
||||
[Grab the packaged .app](https://github.com/whs/runekit/releases/tag/platypus-initial)
|
||||
|
||||
Note that it might be outdated from the source release
|
||||
|
||||
## Running from source
|
||||
|
||||
This guide is assuming that you're willing install a few things and run a few commands in your `terminal` application.
|
||||
|
||||
Not sure how to open the terminal? You can try this [link](https://lmgtfy.app/?q=how+to+open+terminal+on+mac).
|
||||
|
||||
Got your terminal open? Great, time to run some commands.
|
||||
|
||||
### (1/4) Downloading this code
|
||||
|
||||
But first, we need to download this code. If you know how to use `git` to do that, great. If that last sentence made no sense, keep reading.
|
||||
|
||||
We're going to begin by downloading the code as a zip file. You can do this by clicking the green button on the GitHub repo page then clicking `Download Zip`, as shown below:
|
||||
|
||||

|
||||
|
||||
Then, go ahead and open it with `Archive Utitlity`:
|
||||
|
||||

|
||||
|
||||
You should now find the folder in your `Downloads` folder (use `Finder` to find it):
|
||||
|
||||

|
||||
|
||||
Great, we have the code! Next step please.
|
||||
|
||||
### (2/4) Installing Homebrew (a package manager)
|
||||
|
||||
Basically, follow the instructions on their (Homebrew's) website [here](https://brew.sh/).
|
||||
|
||||
Copy the command into your terminal prompt, then hit `enter`:
|
||||
|
||||

|
||||
|
||||
You can check that this worked by closing and reopening your terminal, then running `which brew`. You should get the following output:
|
||||
|
||||

|
||||
|
||||
Awesome, we'll need this tool later. For now, on to Python.
|
||||
|
||||
### (3/4) Installing Python (3.9.1)
|
||||
|
||||
Alright, we're going to install Python the straightforward way. We're going to do that by visiting Python's website [here](https://www.python.org/downloads/release/python-391/) and downloading + running the `macOS 64-bit` installer (which of the two depends on what type of mac you have).
|
||||
|
||||

|
||||
|
||||
If you're having trouble finding the downloads at the link, remember to scroll to the bottom of the page.
|
||||
|
||||
### (4/4) Installing poetry
|
||||
|
||||
Run the following command in your terminal:
|
||||
|
||||
```bash
|
||||
brew install poetry
|
||||
```
|
||||
|
||||
### One last thing...
|
||||
|
||||
Before you go and run the commands from the other page, you'll want to change what file directory your terminal is in to be the folder where you downloaded `runekit-master`. Assuming you downloaded it as instructed in step 1, you can do this by running the following:
|
||||
|
||||
```bash
|
||||
cd ~/Downloads/runekit-master
|
||||
```
|
||||
|
||||
**Note**: if you close and reopen the terminal, you'll need to renavigate to the correct folder (just rerun the above command).
|
||||
|
||||
That simple. Now you can go back to the other file and pick up where you left off, [here](../README.md#running).
|
||||
@@ -1,44 +1,4 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import click
|
||||
from PySide2.QtWebEngine import QtWebEngine
|
||||
from PySide2.QtWidgets import QApplication
|
||||
|
||||
from runekit.browser import init
|
||||
from runekit.game import get_platform_manager
|
||||
from runekit.host import Host
|
||||
|
||||
|
||||
@click.command(
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True,
|
||||
)
|
||||
)
|
||||
@click.option("--game-index", default=0, help="Game instance index, starting from 0")
|
||||
@click.argument("qt_args", nargs=-1, type=click.UNPROCESSED)
|
||||
@click.argument("app_url")
|
||||
def main(app_url, game_index, qt_args):
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
logging.info("Starting QtWebEngine")
|
||||
QtWebEngine.initialize()
|
||||
init()
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
host = Host()
|
||||
|
||||
game_manager = get_platform_manager()
|
||||
logging.info("Scanning for game instances")
|
||||
game_instances = game_manager.get_instances()
|
||||
logging.info("Found %d instances", len(game_instances))
|
||||
game = game_instances[game_index]
|
||||
|
||||
logging.info("Loading app")
|
||||
host.start_app(app_url, game)
|
||||
|
||||
app.exec_()
|
||||
sys.exit(0)
|
||||
|
||||
from runekit.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +1,11 @@
|
||||
[[package]]
|
||||
name = "altgraph"
|
||||
version = "0.17"
|
||||
description = "Python graph (network) package"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "appdirs"
|
||||
version = "1.4.4"
|
||||
@@ -36,6 +44,17 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "1.14.5"
|
||||
description = "Foreign Function Interface for Python calling C code."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
pycparser = "*"
|
||||
|
||||
[[package]]
|
||||
name = "chardet"
|
||||
version = "4.0.0"
|
||||
@@ -52,14 +71,6 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "cython"
|
||||
version = "0.29.21"
|
||||
description = "The Cython compiler for writing C extensions for the Python language."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "2.10"
|
||||
@@ -68,6 +79,17 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[[package]]
|
||||
name = "macholib"
|
||||
version = "1.14"
|
||||
description = "Mach-O header analysis and editing"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
altgraph = ">=0.15"
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "0.4.3"
|
||||
@@ -76,6 +98,25 @@ category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "1.20.1"
|
||||
description = "NumPy is the fundamental package for array computing with Python."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "opencv-python-headless"
|
||||
version = "4.5.1.48"
|
||||
description = "Wrapper package for OpenCV python bindings."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
numpy = ">=1.19.3"
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.8.1"
|
||||
@@ -86,12 +127,56 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "8.1.0"
|
||||
version = "8.1.2"
|
||||
description = "Python Imaging Library (Fork)"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "5.8.0"
|
||||
description = "Cross-platform lib for process and system monitoring in Python."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[package.extras]
|
||||
test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.20"
|
||||
description = "C parser in Python"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller"
|
||||
version = "4.2"
|
||||
description = "PyInstaller bundles a Python application and all its dependencies into a single package."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[package.dependencies]
|
||||
altgraph = "*"
|
||||
macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
|
||||
pyinstaller-hooks-contrib = ">=2020.6"
|
||||
|
||||
[package.extras]
|
||||
encryption = ["tinyaes (>=1.0.0)"]
|
||||
hook_testing = ["pytest (>=2.7.3)", "execnet (>=1.5.0)", "psutil"]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller-hooks-contrib"
|
||||
version = "2021.1"
|
||||
description = "Community maintained hooks for PyInstaller"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-core"
|
||||
version = "7.1"
|
||||
@@ -160,17 +245,6 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <3.10"
|
||||
[package.dependencies]
|
||||
shiboken2 = "5.15.2"
|
||||
|
||||
[[package]]
|
||||
name = "python-xlib"
|
||||
version = "0.29"
|
||||
description = "Python X Library"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
six = ">=1.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2020.11.13"
|
||||
@@ -213,6 +287,14 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
|
||||
[[package]]
|
||||
name = "sysv-ipc"
|
||||
version = "1.1.0"
|
||||
description = "System V IPC primitives (semaphores, shared memory and message queues) for Python"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.10.2"
|
||||
@@ -239,7 +321,7 @@ python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "1.26.2"
|
||||
version = "1.26.3"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -250,12 +332,28 @@ brotli = ["brotlipy (>=0.6.0)"]
|
||||
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "xcffib"
|
||||
version = "0.11.1"
|
||||
description = "A drop in replacement for xpyb, an XCB python binding"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
cffi = ">=1.1.0"
|
||||
six = "*"
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.9,<3.10"
|
||||
content-hash = "7499ffeaf18341e92d2e7b1af5f9b0a21be4b702f988fad58b589708499aed06"
|
||||
content-hash = "a6e17c47f17532e7594f59aa7bcb6bfc0b06c3f27c3b50a4a7cb8b6fd829451a"
|
||||
|
||||
[metadata.files]
|
||||
altgraph = [
|
||||
{file = "altgraph-0.17-py2.py3-none-any.whl", hash = "sha256:c623e5f3408ca61d4016f23a681b9adb100802ca3e3da5e718915a9e4052cebe"},
|
||||
{file = "altgraph-0.17.tar.gz", hash = "sha256:1f05a47122542f97028caf78775a095fbe6a2699b5089de8477eb583167d69aa"},
|
||||
]
|
||||
appdirs = [
|
||||
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
|
||||
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
|
||||
@@ -267,6 +365,45 @@ certifi = [
|
||||
{file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
|
||||
{file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
|
||||
]
|
||||
cffi = [
|
||||
{file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"},
|
||||
{file = "cffi-1.14.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1"},
|
||||
{file = "cffi-1.14.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa"},
|
||||
{file = "cffi-1.14.5-cp27-cp27m-win32.whl", hash = "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3"},
|
||||
{file = "cffi-1.14.5-cp27-cp27m-win_amd64.whl", hash = "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5"},
|
||||
{file = "cffi-1.14.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482"},
|
||||
{file = "cffi-1.14.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6"},
|
||||
{file = "cffi-1.14.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045"},
|
||||
{file = "cffi-1.14.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa"},
|
||||
{file = "cffi-1.14.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406"},
|
||||
{file = "cffi-1.14.5-cp35-cp35m-win32.whl", hash = "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369"},
|
||||
{file = "cffi-1.14.5-cp35-cp35m-win_amd64.whl", hash = "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315"},
|
||||
{file = "cffi-1.14.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892"},
|
||||
{file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"},
|
||||
{file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"},
|
||||
{file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"},
|
||||
{file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"},
|
||||
{file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"},
|
||||
{file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"},
|
||||
{file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"},
|
||||
{file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"},
|
||||
{file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"},
|
||||
{file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"},
|
||||
{file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"},
|
||||
{file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"},
|
||||
{file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"},
|
||||
{file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"},
|
||||
{file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"},
|
||||
{file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"},
|
||||
{file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"},
|
||||
{file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"},
|
||||
{file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"},
|
||||
{file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"},
|
||||
{file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"},
|
||||
{file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"},
|
||||
{file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"},
|
||||
{file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"},
|
||||
]
|
||||
chardet = [
|
||||
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
|
||||
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
|
||||
@@ -275,88 +412,150 @@ click = [
|
||||
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
|
||||
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
|
||||
]
|
||||
cython = [
|
||||
{file = "Cython-0.29.21-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c541b2b49c6638f2b5beb9316726db84a8d1c132bf31b942dae1f9c7f6ad3b92"},
|
||||
{file = "Cython-0.29.21-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b8d8497091c1dc8705d1575c71e908a93b1f127a174b2d472020f3d84263ac28"},
|
||||
{file = "Cython-0.29.21-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:695a6bcaf9e12b1e471dfce96bbecf22a1487adc2ac6106b15960a2b51b97f5d"},
|
||||
{file = "Cython-0.29.21-cp27-cp27m-win32.whl", hash = "sha256:171b9f70ceafcec5852089d0f9c1e75b0d554f46c882cd4e2e4acaba9bd7d148"},
|
||||
{file = "Cython-0.29.21-cp27-cp27m-win_amd64.whl", hash = "sha256:539e59949aab4955c143a468810123bf22d3e8556421e1ce2531ed4893914ca0"},
|
||||
{file = "Cython-0.29.21-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:e93acd1f603a0c1786e0841f066ae7cef014cf4750e3cd06fd03cfdf46361419"},
|
||||
{file = "Cython-0.29.21-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:2922e3031ba9ebbe7cb9200b585cc33b71d66023d78450dcb883f824f4969371"},
|
||||
{file = "Cython-0.29.21-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:497841897942f734b0abc2dead2d4009795ee992267a70a23485fd0e937edc0b"},
|
||||
{file = "Cython-0.29.21-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:0ac10bf476476a9f7ef61ec6e44c280ef434473124ad31d3132b720f7b0e8d2a"},
|
||||
{file = "Cython-0.29.21-cp34-cp34m-win32.whl", hash = "sha256:31c71a615f38401b0dc1f2a5a9a6c421ffd8908c4cd5bbedc4014c1b876488e8"},
|
||||
{file = "Cython-0.29.21-cp34-cp34m-win_amd64.whl", hash = "sha256:c4b78356074fcaac04ecb4de289f11d506e438859877670992ece11f9c90f37b"},
|
||||
{file = "Cython-0.29.21-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:b2f9172e4d6358f33ecce6a4339b5960f9f83eab67ea244baa812737793826b7"},
|
||||
{file = "Cython-0.29.21-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:856c7fb31d247ce713d60116375e1f8153d0291ab5e92cca7d8833a524ba9991"},
|
||||
{file = "Cython-0.29.21-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:715294cd2246b39a8edca464a8366eb635f17213e4a6b9e74e52d8b877a8cb63"},
|
||||
{file = "Cython-0.29.21-cp35-cp35m-win32.whl", hash = "sha256:23f3a00b843a19de8bb4468b087db5b413a903213f67188729782488d67040e0"},
|
||||
{file = "Cython-0.29.21-cp35-cp35m-win_amd64.whl", hash = "sha256:ccb77faeaad99e99c6c444d04862c6cf604204fe0a07d4c8f9cbf2c9012d7d5a"},
|
||||
{file = "Cython-0.29.21-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e272ed97d20b026f4f25a012b25d7d7672a60e4f72b9ca385239d693cd91b2d5"},
|
||||
{file = "Cython-0.29.21-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:8c6e25e9cc4961bb2abb1777c6fa9d0fa2d9b014beb3276cebe69996ff162b78"},
|
||||
{file = "Cython-0.29.21-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:57ead89128dee9609119c93d3926c7a2add451453063147900408a50144598c6"},
|
||||
{file = "Cython-0.29.21-cp36-cp36m-win32.whl", hash = "sha256:0e25c209c75df8785480dcef85db3d36c165dbc0f4c503168e8763eb735704f2"},
|
||||
{file = "Cython-0.29.21-cp36-cp36m-win_amd64.whl", hash = "sha256:a0674f246ad5e1571ef29d4c5ec1d6ecabe9e6c424ad0d6fee46b914d5d24d69"},
|
||||
{file = "Cython-0.29.21-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5da187bebe38030325e1c0b5b8a804d489410be2d384c0ef3ba39493c67eb51e"},
|
||||
{file = "Cython-0.29.21-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:9ce5e5209f8406ffc2b058b1293cce7a954911bb7991e623564d489197c9ba30"},
|
||||
{file = "Cython-0.29.21-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5e545a48f919e40079b0efe7b0e081c74b96f9ef25b9c1ff4cdbd95764426b58"},
|
||||
{file = "Cython-0.29.21-cp37-cp37m-win32.whl", hash = "sha256:c8435959321cf8aec867bbad54b83b7fb8343204b530d85d9ea7a1f5329d5ac2"},
|
||||
{file = "Cython-0.29.21-cp37-cp37m-win_amd64.whl", hash = "sha256:540b3bee0711aac2e99bda4fa0a46dbcd8c74941666bfc1ef9236b1a64eeffd9"},
|
||||
{file = "Cython-0.29.21-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:93f5fed1c9445fb7afe20450cdaf94b0e0356d47cc75008105be89c6a2e417b1"},
|
||||
{file = "Cython-0.29.21-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9207fdedc7e789a3dcaca628176b80c82fbed9ae0997210738cbb12536a56699"},
|
||||
{file = "Cython-0.29.21-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:603b9f1b8e93e8b494d3e89320c410679e21018e48b6cbc77280f5db71f17dc0"},
|
||||
{file = "Cython-0.29.21-cp38-cp38-win32.whl", hash = "sha256:473df5d5e400444a36ed81c6596f56a5b52a3481312d0a48d68b777790f730ae"},
|
||||
{file = "Cython-0.29.21-cp38-cp38-win_amd64.whl", hash = "sha256:b8a8a31b9e8860634adbca30fea1d0c7f08e208b3d7611f3e580e5f20992e5d7"},
|
||||
{file = "Cython-0.29.21-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7ebaa8800c376bcdae596fb1372cb4232a5ef957619d35839520d2786f2debb9"},
|
||||
{file = "Cython-0.29.21-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c111ac9abdf715762e4fb87395e59d61c0fbb6ce79eb2e24167700b6cfa8ba79"},
|
||||
{file = "Cython-0.29.21-py2.py3-none-any.whl", hash = "sha256:5c4276fdcbccdf1e3c1756c7aeb8395e9a36874fa4d30860e7694f43d325ae13"},
|
||||
{file = "Cython-0.29.21.tar.gz", hash = "sha256:e57acb89bd55943c8d8bf813763d20b9099cc7165c0f16b707631a7654be9cad"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
|
||||
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
|
||||
]
|
||||
macholib = [
|
||||
{file = "macholib-1.14-py2.py3-none-any.whl", hash = "sha256:c500f02867515e6c60a27875b408920d18332ddf96b4035ef03beddd782d4281"},
|
||||
{file = "macholib-1.14.tar.gz", hash = "sha256:0c436bc847e7b1d9bda0560351bf76d7caf930fb585a828d13608839ef42c432"},
|
||||
]
|
||||
mypy-extensions = [
|
||||
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
|
||||
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
|
||||
]
|
||||
numpy = [
|
||||
{file = "numpy-1.20.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ae61f02b84a0211abb56462a3b6cd1e7ec39d466d3160eb4e1da8bf6717cdbeb"},
|
||||
{file = "numpy-1.20.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:65410c7f4398a0047eea5cca9b74009ea61178efd78d1be9847fac1d6716ec1e"},
|
||||
{file = "numpy-1.20.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d7e27442599104ee08f4faed56bb87c55f8b10a5494ac2ead5c98a4b289e61f"},
|
||||
{file = "numpy-1.20.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4ed8e96dc146e12c1c5cdd6fb9fd0757f2ba66048bf94c5126b7efebd12d0090"},
|
||||
{file = "numpy-1.20.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:ecb5b74c702358cdc21268ff4c37f7466357871f53a30e6f84c686952bef16a9"},
|
||||
{file = "numpy-1.20.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b9410c0b6fed4a22554f072a86c361e417f0258838957b78bd063bde2c7f841f"},
|
||||
{file = "numpy-1.20.1-cp37-cp37m-win32.whl", hash = "sha256:3d3087e24e354c18fb35c454026af3ed8997cfd4997765266897c68d724e4845"},
|
||||
{file = "numpy-1.20.1-cp37-cp37m-win_amd64.whl", hash = "sha256:89f937b13b8dd17b0099c7c2e22066883c86ca1575a975f754babc8fbf8d69a9"},
|
||||
{file = "numpy-1.20.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a1d7995d1023335e67fb070b2fae6f5968f5be3802b15ad6d79d81ecaa014fe0"},
|
||||
{file = "numpy-1.20.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:60759ab15c94dd0e1ed88241fd4fa3312db4e91d2c8f5a2d4cf3863fad83d65b"},
|
||||
{file = "numpy-1.20.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:125a0e10ddd99a874fd357bfa1b636cd58deb78ba4a30b5ddb09f645c3512e04"},
|
||||
{file = "numpy-1.20.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c26287dfc888cf1e65181f39ea75e11f42ffc4f4529e5bd19add57ad458996e2"},
|
||||
{file = "numpy-1.20.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:7199109fa46277be503393be9250b983f325880766f847885607d9b13848f257"},
|
||||
{file = "numpy-1.20.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:72251e43ac426ff98ea802a931922c79b8d7596480300eb9f1b1e45e0543571e"},
|
||||
{file = "numpy-1.20.1-cp38-cp38-win32.whl", hash = "sha256:c91ec9569facd4757ade0888371eced2ecf49e7982ce5634cc2cf4e7331a4b14"},
|
||||
{file = "numpy-1.20.1-cp38-cp38-win_amd64.whl", hash = "sha256:13adf545732bb23a796914fe5f891a12bd74cf3d2986eed7b7eba2941eea1590"},
|
||||
{file = "numpy-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:104f5e90b143dbf298361a99ac1af4cf59131218a045ebf4ee5990b83cff5fab"},
|
||||
{file = "numpy-1.20.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:89e5336f2bec0c726ac7e7cdae181b325a9c0ee24e604704ed830d241c5e47ff"},
|
||||
{file = "numpy-1.20.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:032be656d89bbf786d743fee11d01ef318b0781281241997558fa7950028dd29"},
|
||||
{file = "numpy-1.20.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:66b467adfcf628f66ea4ac6430ded0614f5cc06ba530d09571ea404789064adc"},
|
||||
{file = "numpy-1.20.1-cp39-cp39-win32.whl", hash = "sha256:12e4ba5c6420917571f1a5becc9338abbde71dd811ce40b37ba62dec7b39af6d"},
|
||||
{file = "numpy-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:9c94cab5054bad82a70b2e77741271790304651d584e2cdfe2041488e753863b"},
|
||||
{file = "numpy-1.20.1-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:9eb551d122fadca7774b97db8a112b77231dcccda8e91a5bc99e79890797175e"},
|
||||
{file = "numpy-1.20.1.zip", hash = "sha256:3bc63486a870294683980d76ec1e3efc786295ae00128f9ea38e2c6e74d5a60a"},
|
||||
]
|
||||
opencv-python-headless = [
|
||||
{file = "opencv-python-headless-4.5.1.48.tar.gz", hash = "sha256:d16825755e7b5a6d8737f93e116670229e1510199e0af9213004e187ae0dbcc5"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:f2011ecb3980bbed283d17d43e0f1221bac88c0cac1a6fb59a056544de2df2f7"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:777fb596e04331f73ef5b0c1faa4d33348f29ca58216d9286355c16f5489c939"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:924aa4c34ead0b817309f42291dc526b2a7755476afe3009d1e275fc3090d92d"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:0e02809db2968e54f3c23340be6ff9a1428b3f03f5dca7cd5aceda66e319ce86"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp36-cp36m-win32.whl", hash = "sha256:522f12dd994e064a30562adfd63b9439099bd7c80819f5261c37ebe593283c9e"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp36-cp36m-win_amd64.whl", hash = "sha256:e3027e0d1b71b68b5cfe1ea9c627e323dff71112c854ba19805258d8fc6c630e"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:f5e40a06116460ef2fd2d1c24be3b65f8bfb5fcdfe433f3fc01bcb4c2eb485bf"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:ba2f0bd46e9534f29969e39f7895cbea9764173102b0c04b7818b8a9910d66e4"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:f7f8e4f7c63c8e95eb210f4cba88d0069a0a964d6335d7a35b07f0d0baa13558"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:aa562c520f46283423ba8fac29099458e42deab697a9abd0491622e421c5c454"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp37-cp37m-win32.whl", hash = "sha256:657cdd9fbbbbb7e898ae3d9b0649b367d0e440d429c714104d069c5612d578bb"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp37-cp37m-win_amd64.whl", hash = "sha256:fe02a943b1a28b505e954fbce24e867119a3eb4351f93adad55c6cfe81a70484"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:526b9e19cf6300f0891d8f427eac1048091912332bb781eb4957a4994bfbe608"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:5f0c7d34fa9953706c2a1a6d2760a91dc5a68cab3df16af61609894c6c8586f1"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:1f7c14f5d4e5af4dc4669fc6b4a983b36072a934c439ac11266b930496da8255"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:7c75680ffdf32d7044415a215d1fc60dbec14a7f2f0b59a85f0f74ef5efcb6ad"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp38-cp38-win32.whl", hash = "sha256:2560dcf3c1158226b066302f777bfe0f65282410b8d90871dd872306c967d1f1"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp38-cp38-win_amd64.whl", hash = "sha256:9aa04a491c534531029fbac61da961fae0bf4abb1786eed9c91befde4ca7bd81"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:96d1da6ad061d8f3509668d398d14dd8265e529d6407f87eb26e7f4ddf043cc0"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:243aea91cc1e36a47c46da4cc408071af39444a48df1fe1539ea8d7990500fd2"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:2e6a9a88617a0ef7219cff24ba78a58416670a77e6ac63975f9009af3319ab63"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:372149d007e20bf556b7687591d22b58b56b3c225f492da051d81587e5dc7411"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp39-cp39-win32.whl", hash = "sha256:65c9ea57be5ebcaed009b3fee14fe59f3b6aa1e573fe08c5039ff057ad593c53"},
|
||||
{file = "opencv_python_headless-4.5.1.48-cp39-cp39-win_amd64.whl", hash = "sha256:a322d4df14c3a5f19701a023b3da91e9b8af8653b0d2ee0c50b0b341212f7343"},
|
||||
]
|
||||
pathspec = [
|
||||
{file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
|
||||
{file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
|
||||
]
|
||||
pillow = [
|
||||
{file = "Pillow-8.1.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:d355502dce85ade85a2511b40b4c61a128902f246504f7de29bbeec1ae27933a"},
|
||||
{file = "Pillow-8.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:93a473b53cc6e0b3ce6bf51b1b95b7b1e7e6084be3a07e40f79b42e83503fbf2"},
|
||||
{file = "Pillow-8.1.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2353834b2c49b95e1313fb34edf18fca4d57446675d05298bb694bca4b194174"},
|
||||
{file = "Pillow-8.1.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:1d208e670abfeb41b6143537a681299ef86e92d2a3dac299d3cd6830d5c7bded"},
|
||||
{file = "Pillow-8.1.0-cp36-cp36m-win32.whl", hash = "sha256:dd9eef866c70d2cbbea1ae58134eaffda0d4bfea403025f4db6859724b18ab3d"},
|
||||
{file = "Pillow-8.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:b09e10ec453de97f9a23a5aa5e30b334195e8d2ddd1ce76cc32e52ba63c8b31d"},
|
||||
{file = "Pillow-8.1.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:b02a0b9f332086657852b1f7cb380f6a42403a6d9c42a4c34a561aa4530d5234"},
|
||||
{file = "Pillow-8.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ca20739e303254287138234485579b28cb0d524401f83d5129b5ff9d606cb0a8"},
|
||||
{file = "Pillow-8.1.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:604815c55fd92e735f9738f65dabf4edc3e79f88541c221d292faec1904a4b17"},
|
||||
{file = "Pillow-8.1.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cf6e33d92b1526190a1de904df21663c46a456758c0424e4f947ae9aa6088bf7"},
|
||||
{file = "Pillow-8.1.0-cp37-cp37m-win32.whl", hash = "sha256:47c0d93ee9c8b181f353dbead6530b26980fe4f5485aa18be8f1fd3c3cbc685e"},
|
||||
{file = "Pillow-8.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:96d4dc103d1a0fa6d47c6c55a47de5f5dafd5ef0114fa10c85a1fd8e0216284b"},
|
||||
{file = "Pillow-8.1.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:7916cbc94f1c6b1301ac04510d0881b9e9feb20ae34094d3615a8a7c3db0dcc0"},
|
||||
{file = "Pillow-8.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3de6b2ee4f78c6b3d89d184ade5d8fa68af0848f9b6b6da2b9ab7943ec46971a"},
|
||||
{file = "Pillow-8.1.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cdbbe7dff4a677fb555a54f9bc0450f2a21a93c5ba2b44e09e54fcb72d2bd13d"},
|
||||
{file = "Pillow-8.1.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:f50e7a98b0453f39000619d845be8b06e611e56ee6e8186f7f60c3b1e2f0feae"},
|
||||
{file = "Pillow-8.1.0-cp38-cp38-win32.whl", hash = "sha256:cb192176b477d49b0a327b2a5a4979552b7a58cd42037034316b8018ac3ebb59"},
|
||||
{file = "Pillow-8.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:6c5275bd82711cd3dcd0af8ce0bb99113ae8911fc2952805f1d012de7d600a4c"},
|
||||
{file = "Pillow-8.1.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:165c88bc9d8dba670110c689e3cc5c71dbe4bfb984ffa7cbebf1fac9554071d6"},
|
||||
{file = "Pillow-8.1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:5e2fe3bb2363b862671eba632537cd3a823847db4d98be95690b7e382f3d6378"},
|
||||
{file = "Pillow-8.1.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7612520e5e1a371d77e1d1ca3a3ee6227eef00d0a9cddb4ef7ecb0b7396eddf7"},
|
||||
{file = "Pillow-8.1.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d673c4990acd016229a5c1c4ee8a9e6d8f481b27ade5fc3d95938697fa443ce0"},
|
||||
{file = "Pillow-8.1.0-cp39-cp39-win32.whl", hash = "sha256:dc577f4cfdda354db3ae37a572428a90ffdbe4e51eda7849bf442fb803f09c9b"},
|
||||
{file = "Pillow-8.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:22d070ca2e60c99929ef274cfced04294d2368193e935c5d6febfd8b601bf865"},
|
||||
{file = "Pillow-8.1.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:a3d3e086474ef12ef13d42e5f9b7bbf09d39cf6bd4940f982263d6954b13f6a9"},
|
||||
{file = "Pillow-8.1.0-pp36-pypy36_pp73-manylinux2010_i686.whl", hash = "sha256:731ca5aabe9085160cf68b2dbef95fc1991015bc0a3a6ea46a371ab88f3d0913"},
|
||||
{file = "Pillow-8.1.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:bba80df38cfc17f490ec651c73bb37cd896bc2400cfba27d078c2135223c1206"},
|
||||
{file = "Pillow-8.1.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:c3d911614b008e8a576b8e5303e3db29224b455d3d66d1b2848ba6ca83f9ece9"},
|
||||
{file = "Pillow-8.1.0-pp37-pypy37_pp73-manylinux2010_i686.whl", hash = "sha256:39725acf2d2e9c17356e6835dccebe7a697db55f25a09207e38b835d5e1bc032"},
|
||||
{file = "Pillow-8.1.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:81c3fa9a75d9f1afafdb916d5995633f319db09bd773cb56b8e39f1e98d90820"},
|
||||
{file = "Pillow-8.1.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:b6f00ad5ebe846cc91763b1d0c6d30a8042e02b2316e27b05de04fa6ec831ec5"},
|
||||
{file = "Pillow-8.1.0.tar.gz", hash = "sha256:887668e792b7edbfb1d3c9d8b5d8c859269a0f0eba4dda562adb95500f60dbba"},
|
||||
{file = "Pillow-8.1.2-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:5cf03b9534aca63b192856aa601c68d0764810857786ea5da652581f3a44c2b0"},
|
||||
{file = "Pillow-8.1.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f91b50ad88048d795c0ad004abbe1390aa1882073b1dca10bfd55d0b8cf18ec5"},
|
||||
{file = "Pillow-8.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5762ebb4436f46b566fc6351d67a9b5386b5e5de4e58fdaa18a1c83e0e20f1a8"},
|
||||
{file = "Pillow-8.1.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e2cd8ac157c1e5ae88b6dd790648ee5d2777e76f1e5c7d184eaddb2938594f34"},
|
||||
{file = "Pillow-8.1.2-cp36-cp36m-win32.whl", hash = "sha256:72027ebf682abc9bafd93b43edc44279f641e8996fb2945104471419113cfc71"},
|
||||
{file = "Pillow-8.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d1d6bca39bb6dd94fba23cdb3eeaea5e30c7717c5343004d900e2a63b132c341"},
|
||||
{file = "Pillow-8.1.2-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:90882c6f084ef68b71bba190209a734bf90abb82ab5e8f64444c71d5974008c6"},
|
||||
{file = "Pillow-8.1.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:89e4c757a91b8c55d97c91fa09c69b3677c227b942fa749e9a66eef602f59c28"},
|
||||
{file = "Pillow-8.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8c4e32218c764bc27fe49b7328195579581aa419920edcc321c4cb877c65258d"},
|
||||
{file = "Pillow-8.1.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:a01da2c266d9868c4f91a9c6faf47a251f23b9a862dce81d2ff583135206f5be"},
|
||||
{file = "Pillow-8.1.2-cp37-cp37m-win32.whl", hash = "sha256:30d33a1a6400132e6f521640dd3f64578ac9bfb79a619416d7e8802b4ce1dd55"},
|
||||
{file = "Pillow-8.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:71b01ee69e7df527439d7752a2ce8fb89e19a32df484a308eca3e81f673d3a03"},
|
||||
{file = "Pillow-8.1.2-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:5a2d957eb4aba9d48170b8fe6538ec1fbc2119ffe6373782c03d8acad3323f2e"},
|
||||
{file = "Pillow-8.1.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:87f42c976f91ca2fc21a3293e25bd3cd895918597db1b95b93cbd949f7d019ce"},
|
||||
{file = "Pillow-8.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:15306d71a1e96d7e271fd2a0737038b5a92ca2978d2e38b6ced7966583e3d5af"},
|
||||
{file = "Pillow-8.1.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:71f31ee4df3d5e0b366dd362007740106d3210fb6a56ec4b581a5324ba254f06"},
|
||||
{file = "Pillow-8.1.2-cp38-cp38-win32.whl", hash = "sha256:98afcac3205d31ab6a10c5006b0cf040d0026a68ec051edd3517b776c1d78b09"},
|
||||
{file = "Pillow-8.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:328240f7dddf77783e72d5ed79899a6b48bc6681f8d1f6001f55933cb4905060"},
|
||||
{file = "Pillow-8.1.2-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:bead24c0ae3f1f6afcb915a057943ccf65fc755d11a1410a909c1fefb6c06ad1"},
|
||||
{file = "Pillow-8.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:81b3716cc9744ffdf76b39afb6247eae754186838cedad0b0ac63b2571253fe6"},
|
||||
{file = "Pillow-8.1.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:63cd413ac52ee3f67057223d363f4f82ce966e64906aea046daf46695e3c8238"},
|
||||
{file = "Pillow-8.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:8565355a29655b28fdc2c666fd9a3890fe5edc6639d128814fafecfae2d70910"},
|
||||
{file = "Pillow-8.1.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1940fc4d361f9cc7e558d6f56ff38d7351b53052fd7911f4b60cd7bc091ea3b1"},
|
||||
{file = "Pillow-8.1.2-cp39-cp39-win32.whl", hash = "sha256:46c2bcf8e1e75d154e78417b3e3c64e96def738c2a25435e74909e127a8cba5e"},
|
||||
{file = "Pillow-8.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:aeab4cd016e11e7aa5cfc49dcff8e51561fa64818a0be86efa82c7038e9369d0"},
|
||||
{file = "Pillow-8.1.2-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:74cd9aa648ed6dd25e572453eb09b08817a1e3d9f8d1bd4d8403d99e42ea790b"},
|
||||
{file = "Pillow-8.1.2-pp36-pypy36_pp73-manylinux2010_i686.whl", hash = "sha256:e5739ae63636a52b706a0facec77b2b58e485637e1638202556156e424a02dc2"},
|
||||
{file = "Pillow-8.1.2-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:903293320efe2466c1ab3509a33d6b866dc850cfd0c5d9cc92632014cec185fb"},
|
||||
{file = "Pillow-8.1.2-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5daba2b40782c1c5157a788ec4454067c6616f5a0c1b70e26ac326a880c2d328"},
|
||||
{file = "Pillow-8.1.2-pp37-pypy37_pp73-manylinux2010_i686.whl", hash = "sha256:1f93f2fe211f1ef75e6f589327f4d4f8545d5c8e826231b042b483d8383e8a7c"},
|
||||
{file = "Pillow-8.1.2-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:6efac40344d8f668b6c4533ae02a48d52fd852ef0654cc6f19f6ac146399c733"},
|
||||
{file = "Pillow-8.1.2-pp37-pypy37_pp73-win32.whl", hash = "sha256:f36c3ff63d6fc509ce599a2f5b0d0732189eed653420e7294c039d342c6e204a"},
|
||||
{file = "Pillow-8.1.2.tar.gz", hash = "sha256:b07c660e014852d98a00a91adfbe25033898a9d90a8f39beb2437d22a203fc44"},
|
||||
]
|
||||
psutil = [
|
||||
{file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"},
|
||||
{file = "psutil-5.8.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:0ae6f386d8d297177fd288be6e8d1afc05966878704dad9847719650e44fc49c"},
|
||||
{file = "psutil-5.8.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:12d844996d6c2b1d3881cfa6fa201fd635971869a9da945cf6756105af73d2df"},
|
||||
{file = "psutil-5.8.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:02b8292609b1f7fcb34173b25e48d0da8667bc85f81d7476584d889c6e0f2131"},
|
||||
{file = "psutil-5.8.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6ffe81843131ee0ffa02c317186ed1e759a145267d54fdef1bc4ea5f5931ab60"},
|
||||
{file = "psutil-5.8.0-cp27-none-win32.whl", hash = "sha256:ea313bb02e5e25224e518e4352af4bf5e062755160f77e4b1767dd5ccb65f876"},
|
||||
{file = "psutil-5.8.0-cp27-none-win_amd64.whl", hash = "sha256:5da29e394bdedd9144c7331192e20c1f79283fb03b06e6abd3a8ae45ffecee65"},
|
||||
{file = "psutil-5.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:74fb2557d1430fff18ff0d72613c5ca30c45cdbfcddd6a5773e9fc1fe9364be8"},
|
||||
{file = "psutil-5.8.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:74f2d0be88db96ada78756cb3a3e1b107ce8ab79f65aa885f76d7664e56928f6"},
|
||||
{file = "psutil-5.8.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99de3e8739258b3c3e8669cb9757c9a861b2a25ad0955f8e53ac662d66de61ac"},
|
||||
{file = "psutil-5.8.0-cp36-cp36m-win32.whl", hash = "sha256:36b3b6c9e2a34b7d7fbae330a85bf72c30b1c827a4366a07443fc4b6270449e2"},
|
||||
{file = "psutil-5.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:52de075468cd394ac98c66f9ca33b2f54ae1d9bff1ef6b67a212ee8f639ec06d"},
|
||||
{file = "psutil-5.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c6a5fd10ce6b6344e616cf01cc5b849fa8103fbb5ba507b6b2dee4c11e84c935"},
|
||||
{file = "psutil-5.8.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:61f05864b42fedc0771d6d8e49c35f07efd209ade09a5afe6a5059e7bb7bf83d"},
|
||||
{file = "psutil-5.8.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0dd4465a039d343925cdc29023bb6960ccf4e74a65ad53e768403746a9207023"},
|
||||
{file = "psutil-5.8.0-cp37-cp37m-win32.whl", hash = "sha256:1bff0d07e76114ec24ee32e7f7f8d0c4b0514b3fae93e3d2aaafd65d22502394"},
|
||||
{file = "psutil-5.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:fcc01e900c1d7bee2a37e5d6e4f9194760a93597c97fee89c4ae51701de03563"},
|
||||
{file = "psutil-5.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6223d07a1ae93f86451d0198a0c361032c4c93ebd4bf6d25e2fb3edfad9571ef"},
|
||||
{file = "psutil-5.8.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d225cd8319aa1d3c85bf195c4e07d17d3cd68636b8fc97e6cf198f782f99af28"},
|
||||
{file = "psutil-5.8.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:28ff7c95293ae74bf1ca1a79e8805fcde005c18a122ca983abf676ea3466362b"},
|
||||
{file = "psutil-5.8.0-cp38-cp38-win32.whl", hash = "sha256:ce8b867423291cb65cfc6d9c4955ee9bfc1e21fe03bb50e177f2b957f1c2469d"},
|
||||
{file = "psutil-5.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:90f31c34d25b1b3ed6c40cdd34ff122b1887a825297c017e4cbd6796dd8b672d"},
|
||||
{file = "psutil-5.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6323d5d845c2785efb20aded4726636546b26d3b577aded22492908f7c1bdda7"},
|
||||
{file = "psutil-5.8.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:245b5509968ac0bd179287d91210cd3f37add77dad385ef238b275bad35fa1c4"},
|
||||
{file = "psutil-5.8.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:90d4091c2d30ddd0a03e0b97e6a33a48628469b99585e2ad6bf21f17423b112b"},
|
||||
{file = "psutil-5.8.0-cp39-cp39-win32.whl", hash = "sha256:ea372bcc129394485824ae3e3ddabe67dc0b118d262c568b4d2602a7070afdb0"},
|
||||
{file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"},
|
||||
{file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"},
|
||||
]
|
||||
pycparser = [
|
||||
{file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
|
||||
{file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
|
||||
]
|
||||
pyinstaller = [
|
||||
{file = "pyinstaller-4.2.tar.gz", hash = "sha256:f5c0eeb2aa663cce9a5404292c0195011fa500a6501c873a466b2e8cad3c950c"},
|
||||
]
|
||||
pyinstaller-hooks-contrib = [
|
||||
{file = "pyinstaller-hooks-contrib-2021.1.tar.gz", hash = "sha256:892310e6363655838485ee748bf1c5e5cade7963686d9af8650ee218a3e0b031"},
|
||||
{file = "pyinstaller_hooks_contrib-2021.1-py2.py3-none-any.whl", hash = "sha256:27558072021857d89524c42136feaa2ffe4f003f1bdf0278f9b24f6902c1759c"},
|
||||
]
|
||||
pyobjc-core = [
|
||||
{file = "pyobjc-core-7.1.tar.gz", hash = "sha256:a0616d5d816b4471f8f782c3a9a8923d2cc85014d88ad4f7fec694be9e6ea349"},
|
||||
@@ -402,10 +601,6 @@ pyside2 = [
|
||||
{file = "PySide2-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-none-win32.whl", hash = "sha256:087a0b719bb967405ea85fd202757c761f1fc73d0e2397bc3a6a15376782ee75"},
|
||||
{file = "PySide2-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-none-win_amd64.whl", hash = "sha256:1316aa22dd330df096daf7b0defe9c00297a66e0b4907f057aaa3e88c53d1aff"},
|
||||
]
|
||||
python-xlib = [
|
||||
{file = "python-xlib-0.29.tar.gz", hash = "sha256:e4bcb756f4aa05be7b82ee21de0ba04d73414018727b42ebd9fbcf409ea75d13"},
|
||||
{file = "python_xlib-0.29-py2.py3-none-any.whl", hash = "sha256:044095d1b1a5eab5a79f8d0b66f811a9ac6acd038dd3bae00cb3dbe90b32a7e3"},
|
||||
]
|
||||
regex = [
|
||||
{file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"},
|
||||
{file = "regex-2020.11.13-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70"},
|
||||
@@ -465,6 +660,13 @@ six = [
|
||||
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
|
||||
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
|
||||
]
|
||||
sysv-ipc = [
|
||||
{file = "sysv_ipc-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ff6e3c98d8aa3e1ae5378d86869c4699c76f94318705d08085c97fb010770a31"},
|
||||
{file = "sysv_ipc-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:30611dccecd8fa0dc9f83d4d465e5047692df0ec9a095abef8b90ccc64afbdaf"},
|
||||
{file = "sysv_ipc-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4bbbe4e5ad5ab859e6d21fc8ff3e3b01082b959fa5c2cd76289dbcd56350c5f9"},
|
||||
{file = "sysv_ipc-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:61190b934b0277f7a189092dcabceee8d9736fd73de90fd9b50ea69f0bac4723"},
|
||||
{file = "sysv_ipc-1.1.0.tar.gz", hash = "sha256:0f063cbd36ec232032e425769ebc871f195a7d183b9af32f9901589ea7129ac3"},
|
||||
]
|
||||
toml = [
|
||||
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
|
||||
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
|
||||
@@ -507,6 +709,9 @@ typing-extensions = [
|
||||
{file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
|
||||
]
|
||||
urllib3 = [
|
||||
{file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"},
|
||||
{file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"},
|
||||
{file = "urllib3-1.26.3-py2.py3-none-any.whl", hash = "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80"},
|
||||
{file = "urllib3-1.26.3.tar.gz", hash = "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"},
|
||||
]
|
||||
xcffib = [
|
||||
{file = "xcffib-0.11.1.tar.gz", hash = "sha256:12949cfe2e68c806efd57596bb9bf3c151f399d4b53e15d1101b2e9baaa66f5a"},
|
||||
]
|
||||
@@ -4,25 +4,25 @@ version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Your Name <you@example.com>"]
|
||||
license = "GPL"
|
||||
include = ["runekit/_resources.py"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9,<3.10"
|
||||
requests = "^2.25.1"
|
||||
PySide2 = "^5.15.2"
|
||||
Pillow = "^8.1.0"
|
||||
python-xlib = [{ version="^0.29", platform="linux" }]
|
||||
sysv-ipc = [{ version="^1.1.0", platform="linux" }]
|
||||
xcffib = [{ version="^0.11.1", platform="linux" }]
|
||||
pyobjc-framework-Quartz = [{ version="7.1", platform="darwin" }]
|
||||
pyobjc-framework-ApplicationServices = [{ version="7.1", platform="darwin" }]
|
||||
pyobjc-framework-CoreText = [{ version="7.1", platform="darwin" }]
|
||||
click = "^7.1.2"
|
||||
psutil = "^5.8.0"
|
||||
opencv-python-headless = "^4.5.1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
Cython = "^0.29.21"
|
||||
black = "^20.8b1"
|
||||
pyinstaller = [{ version="^4.2", platform="darwin" }]
|
||||
|
||||
[tool.poetry.build]
|
||||
script = "build.py"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.1.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
[tool.poetry.scripts]
|
||||
runekit = 'runekit.main:main'
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE RCC>
|
||||
<RCC version="1.0">
|
||||
<qresource>
|
||||
<file>runekit/browser/alt1.js</file>
|
||||
<file>runekit/ui/trayicon.png</file>
|
||||
<file>runekit/ui/skins/default/borderDrag.png</file>
|
||||
<file>runekit/ui/skins/default/exit.png</file>
|
||||
<file>runekit/ui/skins/default/exitHover.png</file>
|
||||
<file>runekit/ui/skins/default/settings.png</file>
|
||||
<file>runekit/ui/skins/default/settingsHover.png</file>
|
||||
<file>runekit/ui/skins/default/minimize.png</file>
|
||||
<file>runekit/ui/skins/default/minimizeHover.png</file>
|
||||
<file>runekit/ui/skins/default/borderTL.png</file>
|
||||
<file>runekit/ui/skins/default/borderT.png</file>
|
||||
<file>runekit/ui/skins/default/borderTR.png</file>
|
||||
<file>runekit/ui/skins/default/borderL.png</file>
|
||||
<file>runekit/ui/skins/default/borderR.png</file>
|
||||
<file>runekit/ui/skins/default/borderBL.png</file>
|
||||
<file>runekit/ui/skins/default/borderB.png</file>
|
||||
<file>runekit/ui/skins/default/borderBR.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
@@ -25,3 +25,13 @@ class RequestHandler(typing.TypedDict):
|
||||
handlerName: str
|
||||
handlerUrl: str
|
||||
handlerScript: str
|
||||
|
||||
|
||||
class RectLike(typing.TypedDict):
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
CaptureMulti = typing.Dict[str, typing.Optional[RectLike]]
|
||||
@@ -5,7 +5,7 @@ import requests
|
||||
from runekit.alt1.schema import AppManifest
|
||||
|
||||
|
||||
def fetch_manifest(url: str) -> AppManifest:
|
||||
def fetch_bom_json(url: str):
|
||||
req = requests.get(url)
|
||||
req.raise_for_status()
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from .app import App
|
||||
from .store import AppStore
|
||||
@@ -13,20 +13,38 @@ if TYPE_CHECKING:
|
||||
class App:
|
||||
host: "Host"
|
||||
manifest: AppManifest
|
||||
app_id: str
|
||||
game_instance: "GameInstance"
|
||||
source_url: Optional[str]
|
||||
window: Optional[AppWindow] = None
|
||||
alt1api: Optional[Alt1Api] = None
|
||||
web_profile: Optional[WebProfile] = None
|
||||
|
||||
def __init__(self, host, manifest, game_instance, source_url=None):
|
||||
def __init__(self, host, app_id, manifest, game_instance, source_url=None):
|
||||
self.host = host
|
||||
self.app_id = app_id
|
||||
self.manifest = manifest
|
||||
self.game_instance = game_instance
|
||||
self.source_url = source_url
|
||||
|
||||
def close(self):
|
||||
if self.window:
|
||||
self.window.deleteLater()
|
||||
self.window = None
|
||||
if self.alt1api:
|
||||
self.alt1api.deleteLater()
|
||||
self.alt1api = None
|
||||
if self.web_profile:
|
||||
self.web_profile.deleteLater()
|
||||
self.web_profile = None
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def get_window(self, **kwargs) -> AppWindow:
|
||||
self.window = AppWindow(app=self, **kwargs)
|
||||
self.window.winId() # force native window
|
||||
# self.game_instance.embed_window(self.window.windowHandle()) # FIXME
|
||||
return self.window
|
||||
|
||||
def get_api(self) -> Alt1Api:
|
||||
@@ -49,6 +67,7 @@ class App:
|
||||
|
||||
@property
|
||||
def absolute_app_url(self) -> str:
|
||||
# TODO: Remove
|
||||
if self.source_url:
|
||||
return urljoin(self.source_url, self.manifest["appUrl"])
|
||||
|
||||
@@ -56,6 +75,7 @@ class App:
|
||||
|
||||
@property
|
||||
def absolute_config_url(self) -> str:
|
||||
# TODO: Remove
|
||||
if self.source_url:
|
||||
return urljoin(self.source_url, self.manifest["configUrl"])
|
||||
|
||||
@@ -63,6 +83,7 @@ class App:
|
||||
|
||||
@property
|
||||
def absolute_icon_url(self) -> Optional[str]:
|
||||
# TODO: Remove
|
||||
if self.source_url and "iconUrl" in self.manifest:
|
||||
return urljoin(self.source_url, self.manifest["iconUrl"])
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
from typing import Iterator, Tuple, Optional, Union, List
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from PySide2.QtCore import (
|
||||
QObject,
|
||||
QSettings,
|
||||
QThread,
|
||||
Signal,
|
||||
Slot,
|
||||
QStandardPaths,
|
||||
)
|
||||
from PySide2.QtGui import QIcon, QPixmap
|
||||
from PySide2.QtWidgets import QProgressDialog, QMessageBox
|
||||
|
||||
from runekit.alt1.schema import AppManifest
|
||||
from runekit.alt1.utils import fetch_bom_json
|
||||
|
||||
REGISTRY_URL = "https://runeapps.org/data/alt1/defaultapps.json"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def app_id(manifest_url):
|
||||
assert "://" in manifest_url
|
||||
return hashlib.blake2b(manifest_url.encode("utf8"), digest_size=16).hexdigest()
|
||||
|
||||
|
||||
class _FetchRegistryThread(QThread):
|
||||
progress = Signal(int)
|
||||
items = Signal(int)
|
||||
label = Signal(str)
|
||||
failed = Signal(tuple)
|
||||
|
||||
canceled = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: "AppStore",
|
||||
apps_manifest: Optional[str] = None,
|
||||
apps: Optional[List[str]] = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
assert (not apps_manifest and apps) or (
|
||||
not apps and apps_manifest
|
||||
), "One of apps_manifest or apps must be set but not both. apps_manifest={}, apps={}".format(
|
||||
apps_manifest, apps
|
||||
)
|
||||
|
||||
self.appstore = parent
|
||||
self.apps_manifest = apps_manifest
|
||||
self.apps = apps
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
progress_extra = 0
|
||||
if self.apps_manifest:
|
||||
apps = fetch_bom_json(REGISTRY_URL)
|
||||
self.items.emit(len(apps) + 1)
|
||||
self.progress.emit(1)
|
||||
progress_extra = 1
|
||||
else:
|
||||
apps = self.apps
|
||||
self.items.emit(len(apps))
|
||||
|
||||
for index, app in enumerate(apps):
|
||||
if type(app) == str:
|
||||
self.label.emit(f"Installing app {app}")
|
||||
self.add_app(app)
|
||||
else:
|
||||
self.label.emit(f"Installing {app['name']}")
|
||||
url = urljoin(REGISTRY_URL, app["url"])
|
||||
self.add_app(url, app["folder"])
|
||||
|
||||
self.progress.emit(index + progress_extra + 1)
|
||||
|
||||
if self.canceled:
|
||||
return
|
||||
finally:
|
||||
self.items.emit(100)
|
||||
self.progress.emit(100)
|
||||
|
||||
def add_app(self, url, folder=""):
|
||||
try:
|
||||
manifest = fetch_bom_json(url)
|
||||
self.appstore.add_app(url, manifest)
|
||||
self.appstore.add_app_to_folder(app_id(url), folder)
|
||||
except:
|
||||
logger.warning("Unable to add application %s", url, exc_info=True)
|
||||
self.failed.emit(sys.exc_info())
|
||||
|
||||
@Slot()
|
||||
def cancel(self):
|
||||
self.canceled = True
|
||||
|
||||
|
||||
class AppStore(QObject):
|
||||
app_change = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.settings = QSettings(self)
|
||||
|
||||
qt_write_base = Path(
|
||||
QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation)
|
||||
)
|
||||
qt_write_base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.icon_write_dir = qt_write_base / "app_icons"
|
||||
self.icon_write_dir.mkdir(exist_ok=True)
|
||||
|
||||
def has_default_apps(self) -> bool:
|
||||
return self.settings.value("apps/_meta/isDefaultLoaded")
|
||||
|
||||
def load_default_apps(self):
|
||||
def on_progress(value):
|
||||
if value != self.app_progress.maximum():
|
||||
return
|
||||
|
||||
self.settings.setValue("apps/_meta/isDefaultLoaded", True)
|
||||
logger.info("Default apps loaded")
|
||||
self.add_app_thread.progress.disconnect(on_progress)
|
||||
del self.app_progress
|
||||
del self.add_app_thread
|
||||
|
||||
self.app_progress = QProgressDialog("Loading default apps", "Cancel", 0, 100)
|
||||
|
||||
self.add_app_thread = _FetchRegistryThread(self, apps_manifest=REGISTRY_URL)
|
||||
self.add_app_thread.progress.connect(self.app_progress.setValue)
|
||||
self.add_app_thread.progress.connect(on_progress)
|
||||
self.add_app_thread.items.connect(self.app_progress.setMaximum)
|
||||
self.add_app_thread.label.connect(self.app_progress.setLabelText)
|
||||
self.app_progress.canceled.connect(self.add_app_thread.cancel)
|
||||
self.add_app_thread.start()
|
||||
|
||||
self.app_progress.show()
|
||||
|
||||
def add_app_ui(self, manifests: List[str]):
|
||||
def on_progress(value):
|
||||
if value != self.app_progress.maximum():
|
||||
return
|
||||
|
||||
self.add_app_thread.progress.disconnect(on_progress)
|
||||
del self.app_progress
|
||||
del self.add_app_thread
|
||||
|
||||
def on_failed(exc_info):
|
||||
msg = QMessageBox(
|
||||
QMessageBox.Critical,
|
||||
"Fail to add application",
|
||||
f"Failed to add application: \n\n{exc_info[0].__name__}: {exc_info[1]}",
|
||||
)
|
||||
msg.setDetailedText("".join(traceback.format_exception(*exc_info)))
|
||||
msg.exec_()
|
||||
|
||||
self.app_progress = QProgressDialog("Installing app", "Cancel", 0, 100)
|
||||
|
||||
self.add_app_thread = _FetchRegistryThread(self, apps=manifests)
|
||||
self.add_app_thread.progress.connect(self.app_progress.setValue)
|
||||
self.add_app_thread.progress.connect(on_progress)
|
||||
self.add_app_thread.items.connect(self.app_progress.setMaximum)
|
||||
self.add_app_thread.label.connect(self.app_progress.setLabelText)
|
||||
self.add_app_thread.failed.connect(on_failed)
|
||||
self.app_progress.canceled.connect(self.add_app_thread.cancel)
|
||||
self.add_app_thread.start()
|
||||
|
||||
self.app_progress.exec_()
|
||||
|
||||
def add_app(self, manifest_url: str, manifest: AppManifest):
|
||||
appid = app_id(manifest_url)
|
||||
|
||||
try:
|
||||
manifest["appUrl"] = urljoin(manifest_url, manifest["appUrl"])
|
||||
manifest["iconUrl"] = (
|
||||
urljoin(manifest_url, manifest["iconUrl"])
|
||||
if "iconUrl" in manifest and manifest["iconUrl"]
|
||||
else ""
|
||||
)
|
||||
manifest["configUrl"] = urljoin(manifest_url, manifest["configUrl"])
|
||||
except KeyError:
|
||||
raise AddAppError(manifest_url)
|
||||
|
||||
if manifest["iconUrl"]:
|
||||
self.download_app_icon(appid, manifest["iconUrl"])
|
||||
|
||||
self.settings.setValue(f"apps/{appid}", json.dumps(manifest))
|
||||
self.app_change.emit()
|
||||
logger.info(
|
||||
"Application %s (%s:%s) installed", manifest["appName"], appid, manifest_url
|
||||
)
|
||||
|
||||
def mkdir(self, folder: str):
|
||||
assert folder
|
||||
assert "/" not in folder
|
||||
|
||||
settings = QSettings()
|
||||
settings.beginGroup(f"apps/_folder/{folder}")
|
||||
settings.setValue("_dir", "true")
|
||||
self.app_change.emit()
|
||||
|
||||
def add_app_to_folder(self, appid: str, folder: str = "", _emit=True):
|
||||
assert "/" not in folder
|
||||
settings = QSettings()
|
||||
settings.beginGroup(f"apps/_folder/{folder}")
|
||||
|
||||
last_id = 0
|
||||
for key in settings.childKeys():
|
||||
if "_" in key:
|
||||
continue
|
||||
|
||||
last_id = max(int(key), last_id)
|
||||
if appid == settings.value(key):
|
||||
settings.endGroup()
|
||||
return
|
||||
|
||||
keys = [int(x) for x in settings.childKeys() if "_" not in x]
|
||||
last_id = max(keys) if keys else 0
|
||||
settings.endGroup()
|
||||
|
||||
settings.setValue(f"apps/_folder/{folder}/{last_id + 1}", appid)
|
||||
|
||||
if _emit:
|
||||
self.app_change.emit()
|
||||
|
||||
def delete_app_from_folder(self, appid: str, folder: str):
|
||||
assert "/" not in folder
|
||||
settings = QSettings()
|
||||
settings.beginGroup(f"apps/_folder/{folder}")
|
||||
|
||||
for key in settings.childKeys():
|
||||
if appid == settings.value(key):
|
||||
settings.remove(key)
|
||||
self.app_change.emit()
|
||||
return
|
||||
|
||||
def remove_app(self, appid: str):
|
||||
settings = QSettings()
|
||||
settings.beginGroup("apps/_folder/")
|
||||
|
||||
def recurse():
|
||||
for key in settings.childKeys():
|
||||
if settings.value(key) == appid:
|
||||
settings.remove(key)
|
||||
|
||||
for childGroup in settings.childGroups():
|
||||
settings.beginGroup(childGroup)
|
||||
recurse()
|
||||
settings.endGroup()
|
||||
|
||||
recurse()
|
||||
|
||||
self.settings.remove(f"apps/{appid}")
|
||||
|
||||
icon_file = self.icon_write_dir / (appid + ".png")
|
||||
icon_file.unlink(True)
|
||||
|
||||
self.app_change.emit()
|
||||
|
||||
def rmdir(self, folder: str):
|
||||
assert folder
|
||||
assert "/" not in folder
|
||||
|
||||
settings = QSettings()
|
||||
settings.beginGroup(f"apps/_folder/{folder}")
|
||||
|
||||
for key in settings.childKeys():
|
||||
if "_" in key:
|
||||
continue
|
||||
|
||||
try:
|
||||
int(key)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
self.add_app_to_folder(settings.value(key), "", _emit=False)
|
||||
|
||||
settings.remove("")
|
||||
self.app_change.emit()
|
||||
|
||||
def download_app_icon(self, appid: str, url: str):
|
||||
dest = self.icon_write_dir / (appid + ".png")
|
||||
req = requests.get(url)
|
||||
with dest.open("wb") as fp:
|
||||
fp.write(req.content)
|
||||
|
||||
logger.info("App icon %s wrote to %s", appid, str(dest))
|
||||
|
||||
def icon(self, appid: str) -> Optional[QIcon]:
|
||||
fn = QStandardPaths.locate(
|
||||
QStandardPaths.AppConfigLocation, "app_icons/" + appid + ".png"
|
||||
)
|
||||
if fn == "":
|
||||
return None
|
||||
|
||||
return QIcon(QPixmap(fn))
|
||||
|
||||
def all_apps(self) -> Iterator[Tuple[str, AppManifest]]:
|
||||
settings = QSettings()
|
||||
settings.beginGroup("apps")
|
||||
for appid in settings.childKeys():
|
||||
if appid.startswith("_"):
|
||||
continue
|
||||
|
||||
manifest = json.loads(settings.value(appid))
|
||||
yield appid, manifest
|
||||
|
||||
settings.endGroup()
|
||||
|
||||
def list_app(self, root: str) -> Iterator[Tuple[str, Union[AppManifest, None]]]:
|
||||
settings = QSettings()
|
||||
|
||||
settings.beginGroup(f"apps/_folder/" + root)
|
||||
for key in settings.childGroups():
|
||||
yield key, None
|
||||
for key in settings.childKeys():
|
||||
try:
|
||||
int(key)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
appid = settings.value(key)
|
||||
yield appid, self[appid]
|
||||
|
||||
def __iter__(self):
|
||||
yield from self.all_apps()
|
||||
|
||||
def __getitem__(self, item: str) -> AppManifest:
|
||||
assert not item.startswith("_")
|
||||
return json.loads(self.settings.value(f"apps/{item}"))
|
||||
|
||||
|
||||
class AddAppError(ValueError):
|
||||
def __init__(self, url):
|
||||
super().__init__(f"Unable to parse manifest of application at {url}")
|
||||
@@ -1,37 +1,100 @@
|
||||
import typing
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import Qt, Slot, QRect
|
||||
from PySide2.QtGui import QIcon
|
||||
from PySide2.QtCore import Qt, Slot, QRect, QObject, QUrl
|
||||
from PySide2.QtGui import QIcon, QDesktopServices
|
||||
from PySide2.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
|
||||
from PySide2.QtWidgets import QMainWindow
|
||||
|
||||
from runekit.browser import Alt1WebChannel
|
||||
from runekit.ui.game_snap import GameSnapMixin
|
||||
from runekit.ui.windowframe import WindowFrame
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from runekit.app.app import App
|
||||
|
||||
|
||||
class BrowserWindow(QMainWindow):
|
||||
class PageClass(QWebEnginePage):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
|
||||
|
||||
def javaScriptConsoleMessage(
|
||||
self,
|
||||
level: QWebEnginePage.JavaScriptConsoleMessageLevel,
|
||||
message: str,
|
||||
line_no: int,
|
||||
src_id: str,
|
||||
):
|
||||
logger = self.logger.getChild(self.url().toString())
|
||||
log = logger.debug
|
||||
if level == QWebEnginePage.InfoMessageLevel:
|
||||
log = logger.debug
|
||||
elif level == QWebEnginePage.WarningMessageLevel:
|
||||
log = logger.warning
|
||||
elif level == QWebEnginePage.ErrorMessageLevel:
|
||||
log = logger.error
|
||||
log("%s", message)
|
||||
|
||||
def acceptNavigationRequest(
|
||||
self, url: QUrl, type_: QWebEnginePage.NavigationType, is_main_frame: bool
|
||||
) -> bool:
|
||||
if not is_main_frame:
|
||||
return super().acceptNavigationRequest(url, type_, is_main_frame)
|
||||
|
||||
if (
|
||||
type_ != QWebEnginePage.NavigationTypeTyped
|
||||
and url.authority() != self.url().authority()
|
||||
):
|
||||
# The web is only allowed pages on the same origin
|
||||
# Cross origin pages would open in browser
|
||||
if url.scheme() in ("http", "https"):
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
return False
|
||||
|
||||
return super().acceptNavigationRequest(url, type_, is_main_frame)
|
||||
|
||||
|
||||
class BrowserWindow(GameSnapMixin, QMainWindow):
|
||||
app: "App"
|
||||
browser: QWebEngineView
|
||||
page_class = PageClass
|
||||
framed = False
|
||||
|
||||
def __init__(self, app: "App", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.app = app
|
||||
|
||||
if self.framed and sys.platform != "darwin":
|
||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||
self.frame = WindowFrame(parent=self)
|
||||
self.frame.on_exit.connect(self.close)
|
||||
self.setCentralWidget(self.frame)
|
||||
|
||||
self._setup_browser()
|
||||
self.setWindowTitle(self.app.manifest["appName"])
|
||||
self.setAttribute(Qt.WA_DeleteOnClose)
|
||||
|
||||
def _setup_browser(self):
|
||||
self.browser = BrowserView(self)
|
||||
self.setCentralWidget(self.browser)
|
||||
|
||||
page = QWebEnginePage(self.app.get_web_profile(), self.browser)
|
||||
if hasattr(self, "frame"):
|
||||
self.frame.set_content(self.browser)
|
||||
else:
|
||||
self.setCentralWidget(self.browser)
|
||||
|
||||
self.browser.setPage(self.get_page())
|
||||
|
||||
def get_page(self) -> QWebEnginePage:
|
||||
page = self.page_class(self.app.get_web_profile(), self.browser)
|
||||
page.setWebChannel(Alt1WebChannel(app=self.app, parent=self.browser))
|
||||
page.geometryChangeRequested.connect(self.on_geometry_change)
|
||||
page.iconChanged.connect(self.on_icon_changed)
|
||||
page.windowCloseRequested.connect(self.on_window_close)
|
||||
self.browser.setPage(page)
|
||||
page.featurePermissionRequested.connect(self.on_permission_request)
|
||||
return page
|
||||
|
||||
@Slot(QRect)
|
||||
def on_geometry_change(self, size: QRect):
|
||||
@@ -45,13 +108,50 @@ class BrowserWindow(QMainWindow):
|
||||
def on_window_close(self):
|
||||
self.close()
|
||||
|
||||
@Slot(QUrl, QWebEnginePage.Feature)
|
||||
def on_permission_request(self, origin: QUrl, feature: QWebEnginePage.Feature):
|
||||
if feature == QWebEnginePage.Notifications and self.app.has_permission(
|
||||
"overlay"
|
||||
):
|
||||
# FIXME: This doesn't really work - PySide2 doesn't have QWebEngineNotification
|
||||
self.browser.page().setFeaturePermission(
|
||||
origin, feature, QWebEnginePage.PermissionGrantedByUser
|
||||
)
|
||||
elif (
|
||||
feature in (QWebEnginePage.DesktopVideoCapture, QWebEnginePage.DesktopAudioVideoCapture)
|
||||
and self.app.has_permission('pixel')
|
||||
):
|
||||
self.browser.page().setFeaturePermission(
|
||||
origin, feature, QWebEnginePage.PermissionGrantedByUser
|
||||
)
|
||||
else:
|
||||
self.browser.page().setFeaturePermission(
|
||||
origin, feature, QWebEnginePage.PermissionDeniedByUser
|
||||
)
|
||||
|
||||
|
||||
class BrowserView(QWebEngineView):
|
||||
def createWindow(self, type_: QWebEnginePage.WebWindowType) -> QWebEngineView:
|
||||
from runekit.app.view.popup_window import PopupWindow
|
||||
|
||||
app_window = typing.cast(BrowserWindow, self.parent())
|
||||
app_window = self.browser_window()
|
||||
popup = PopupWindow(app=app_window.app, parent=app_window)
|
||||
popup.setWindowIcon(app_window.windowIcon())
|
||||
popup.show()
|
||||
|
||||
def show(ok: bool):
|
||||
popup.browser.loadFinished.disconnect(show)
|
||||
if ok:
|
||||
popup.show()
|
||||
else:
|
||||
popup.close()
|
||||
|
||||
popup.browser.loadFinished.connect(show)
|
||||
return popup.browser
|
||||
|
||||
def browser_window(self) -> BrowserWindow:
|
||||
parent: QObject = self.parent()
|
||||
while parent is not None:
|
||||
if isinstance(parent, BrowserWindow):
|
||||
return parent
|
||||
|
||||
parent = parent.parent()
|
||||
@@ -1,9 +1,9 @@
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from PySide2.QtCore import QSize, Qt, QRunnable, Slot, QObject, Signal, QThreadPool
|
||||
from PySide2.QtGui import QIcon, QPixmap
|
||||
from PySide2.QtCore import QSize, Qt, QThreadPool, QSettings
|
||||
from PySide2.QtGui import QIcon
|
||||
|
||||
from runekit.app.view.browser_window import BrowserWindow
|
||||
|
||||
@@ -14,16 +14,19 @@ class AppWindow(BrowserWindow):
|
||||
app_icon: Optional[QIcon]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.settings = QSettings()
|
||||
super().__init__(**kwargs)
|
||||
self.settings.setParent(self)
|
||||
|
||||
# TODO: Hide from taskbar/group this as part of one big window?
|
||||
super().__init__(
|
||||
flags=Qt.WindowTitleHint
|
||||
| Qt.WindowCloseButtonHint
|
||||
| Qt.NoDropShadowWindowHint
|
||||
| Qt.WindowStaysOnTopHint,
|
||||
**kwargs
|
||||
)
|
||||
flags = Qt.NoDropShadowWindowHint | Qt.WindowStaysOnTopHint | Qt.Window
|
||||
if self.framed:
|
||||
flags |= Qt.CustomizeWindowHint
|
||||
self.setWindowFlags(flags)
|
||||
|
||||
self.pool = QThreadPool(parent=self)
|
||||
self.setWindowTitle(self.app.manifest["appName"])
|
||||
self.snap_to_game()
|
||||
|
||||
self.logger = logging.getLogger(
|
||||
__name__
|
||||
@@ -34,20 +37,14 @@ class AppWindow(BrowserWindow):
|
||||
)
|
||||
|
||||
self.browser.load(self.app.absolute_app_url)
|
||||
self._update_app_icon()
|
||||
|
||||
def _update_app_icon(self):
|
||||
if self.app.absolute_icon_url:
|
||||
self.logger.debug("Fetching app icon")
|
||||
icon_job = IconFetcher(self.app.absolute_icon_url)
|
||||
self.app_icon = self.app.host.app_store.icon(self.app.app_id)
|
||||
if self.app_icon:
|
||||
self.setWindowIcon(self.app_icon)
|
||||
|
||||
def update_app_icon(icon: QPixmap):
|
||||
self.logger.debug("App icon loaded")
|
||||
self.app_icon = icon
|
||||
self.setWindowIcon(QIcon(icon))
|
||||
|
||||
icon_job.signals.finished.connect(update_app_icon)
|
||||
self.pool.start(icon_job)
|
||||
@property
|
||||
def framed(self) -> bool:
|
||||
return self.settings.value("settings/styledBorder", "true") == "true"
|
||||
|
||||
def minimumSize(self) -> QSize:
|
||||
return QSize(self.app.manifest["minWidth"], self.app.manifest["minHeight"])
|
||||
@@ -59,22 +56,3 @@ class AppWindow(BrowserWindow):
|
||||
|
||||
def maximumSize(self) -> QSize:
|
||||
return QSize(self.app.manifest["maxWidth"], self.app.manifest["maxHeight"])
|
||||
|
||||
|
||||
class IconFetcherSignal(QObject):
|
||||
finished = Signal(QPixmap)
|
||||
|
||||
|
||||
class IconFetcher(QRunnable):
|
||||
def __init__(self, icon_url: str):
|
||||
super().__init__()
|
||||
self.icon_url = icon_url
|
||||
self.signals = IconFetcherSignal()
|
||||
|
||||
@Slot()
|
||||
def run(self):
|
||||
icon_req = requests.get(self.icon_url)
|
||||
icon_req.raise_for_status()
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(icon_req.content)
|
||||
self.signals.finished.emit(pixmap)
|
||||
@@ -1,7 +1,10 @@
|
||||
from PySide2.QtWebEngine import QtWebEngine
|
||||
|
||||
from .api import Alt1WebChannel, Alt1Api
|
||||
from .profile import WebProfile
|
||||
from .scheme import register as register_scheme
|
||||
|
||||
|
||||
def init():
|
||||
QtWebEngine.initialize()
|
||||
register_scheme()
|
||||
@@ -8,6 +8,20 @@
|
||||
// (even though if discovered, still cannot bypass profiles restriction)
|
||||
const RPC_TOKEN = '%%RPC_TOKEN%%'; // replaced in profile.py
|
||||
|
||||
if (window.parent !== window) {
|
||||
// If running in subframe just use parent's alt1.
|
||||
// Can't run separate instances as we have request ids
|
||||
// that the native part don't know about
|
||||
let wnd = window;
|
||||
while(wnd.parent && wnd.parent !== wnd) {
|
||||
// this is not going to work cross origin
|
||||
wnd = wnd.parent;
|
||||
}
|
||||
|
||||
window.alt1 = wnd.alt1;
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: Even though RPC is secure, this is not...
|
||||
let syncChan;
|
||||
let api;
|
||||
@@ -20,15 +34,25 @@
|
||||
});
|
||||
};
|
||||
if (typeof window.qt !== 'undefined') {
|
||||
setup()
|
||||
setup();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', setup);
|
||||
}
|
||||
});
|
||||
|
||||
function str2ab(str) {
|
||||
let buf = new ArrayBuffer(str.length);
|
||||
let bufView = new Uint8ClampedArray(buf);
|
||||
for (let i=0, strLen=str.length; i < strLen; i++) {
|
||||
bufView[i] = str.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
return bufView;
|
||||
}
|
||||
|
||||
function syncRpc(msg, json) {
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', 'rk:' + JSON.stringify(msg), false); // sync xhr
|
||||
xhr.overrideMimeType('text/plain; charset=x-user-defined');
|
||||
xhr.setRequestHeader('token', RPC_TOKEN);
|
||||
xhr.send(null);
|
||||
|
||||
@@ -38,6 +62,25 @@
|
||||
return xhr.responseText;
|
||||
}
|
||||
|
||||
function asyncRpc(msg, responseType='') {
|
||||
return new Promise((resolve, reject) => {
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', 'rk:' + JSON.stringify(msg));
|
||||
xhr.setRequestHeader('token', RPC_TOKEN);
|
||||
xhr.responseType = responseType;
|
||||
xhr.addEventListener('load', (e) => {
|
||||
resolve(xhr.response);
|
||||
});
|
||||
xhr.addEventListener('error' ,(e) => {
|
||||
e.xhr = xhr;
|
||||
reject(e);
|
||||
});
|
||||
xhr.send();
|
||||
|
||||
return xhr.response;
|
||||
})
|
||||
}
|
||||
|
||||
function emit(param){
|
||||
let listeners = alt1.events[param.eventName];
|
||||
for(let i = 0; i < listeners.length; i++){
|
||||
@@ -50,11 +93,12 @@
|
||||
}
|
||||
|
||||
let lastGameActivity = window.performance.now();
|
||||
let drawCallId = 0;
|
||||
|
||||
window.alt1 = {
|
||||
overlay: {},
|
||||
version: '1.1.1',
|
||||
versionint: 1001001,
|
||||
version: '1.2.0',
|
||||
versionint: 1002000,
|
||||
maxtransfer: 4000000, // also hardcoded in lib...
|
||||
skinName: '',
|
||||
captureMethod: '',
|
||||
@@ -63,7 +107,6 @@
|
||||
permissionOverlay: true,
|
||||
permissionPixel: true,
|
||||
lastWorldHop: 0,
|
||||
currentWorld: -1,
|
||||
rsPing: 0,
|
||||
rsFps: 60,
|
||||
openInfo: '{}',
|
||||
@@ -147,6 +190,11 @@
|
||||
return window.performance.now() - lastGameActivity;
|
||||
},
|
||||
|
||||
get currentWorld() {
|
||||
if(!syncChan) return -1;
|
||||
return api.world || -1;
|
||||
},
|
||||
|
||||
userResize(left, top, right, bottom) {
|
||||
window.resizeTo(right-left, bottom-top);
|
||||
},
|
||||
@@ -160,11 +208,13 @@
|
||||
});
|
||||
},
|
||||
|
||||
// openBrowser(url) {
|
||||
// },
|
||||
openBrowser(url) {
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
// clearBinds() {
|
||||
// },
|
||||
clearBinds() {
|
||||
window.alt1.clearTooltip();
|
||||
},
|
||||
|
||||
// registerStatusDaemon(serverUrl, state) {
|
||||
// },
|
||||
@@ -172,11 +222,15 @@
|
||||
// return '';
|
||||
// },
|
||||
|
||||
// showNotification() {
|
||||
// },
|
||||
showNotification(title, message, icon) {
|
||||
// FIXME: Just use HTML5 Notification once QWebEngineNotification is implemented
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.showNotification(title, message, icon);
|
||||
});
|
||||
},
|
||||
|
||||
closeApp() {
|
||||
window.close()
|
||||
window.close();
|
||||
},
|
||||
|
||||
setTooltip(tooltip) {
|
||||
@@ -197,42 +251,71 @@
|
||||
},
|
||||
|
||||
setTitleBarText(text) {
|
||||
document.title = text;
|
||||
|
||||
},
|
||||
|
||||
overLayRect(color, x, y, w, h, time, lineWidth) {
|
||||
console.trace('overLayRect');
|
||||
return false;
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.overlayRect(drawCallId++, color, x, y, w, h, time, lineWidth);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
overLayText(str, color, size, x, y, time) {
|
||||
return alt1.overLayTextEx(str, color, size, x, y, time, '', false, true);
|
||||
},
|
||||
overLayTextEx(str, color, size, x, y, time, fontname, centered, shadow) {
|
||||
console.trace('overLayTextEx');
|
||||
return false;
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.overlayTextEx(drawCallId++, str, color, size, x, y, time, fontname, centered, shadow);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
overLayLine(color, width, x1, y1, x2, y2, time) {
|
||||
console.trace('overLayLine');
|
||||
return false;
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.overlayLine(drawCallId++, color, width, x1, y1, x2, y2, time);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
overLayImage(x, y, imgstr, imgwidth, time) {
|
||||
console.trace('overLayImage');
|
||||
return false;
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.overlayImage(drawCallId++, x, y, imgstr, imgwidth, time);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
overLayClearGroup(group) {
|
||||
console.trace('overLayClearGroup');
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.overlayClearGroup(drawCallId++, group);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
overLaySetGroup(group) {
|
||||
console.trace('overLaySetGroup');
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.overlaySetGroup(drawCallId++, group);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
overLayFreezeGroup(group) {
|
||||
console.trace('overLayFreezeGroup');
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.overlayFreezeGroup(drawCallId++, group);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
overLayContinueGroup(group) {
|
||||
console.trace('overLayContinueGroup');
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.overlayContinueGroup(drawCallId++, group);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
overLayRefreshGroup(group) {
|
||||
console.trace('overLayRefreshGroup');
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.overlayRefreshGroup(drawCallId++, group);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
overLaySetGroupZIndex(groupname, zIndex) {
|
||||
console.trace('overLaySetGroupZIndex');
|
||||
overLaySetGroupZIndex(group, zIndex) {
|
||||
channel.then(function(chan){
|
||||
chan.objects.alt1.overlaySetGroupZIndex(drawCallId++, group, zIndex);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
getRegion(x, y, w, h) {
|
||||
@@ -268,7 +351,31 @@
|
||||
// },
|
||||
// bindFindSubImg(id, imgstr, imgwidth, x, y, w, h) {
|
||||
// return '';
|
||||
// }
|
||||
// },
|
||||
capture(x, y, w, h) {
|
||||
let data = syncRpc({func: 'getRegionRaw', x: x, y: y, w: w, h: h});
|
||||
return str2ab(data);
|
||||
},
|
||||
captureAsync(x, y, w, h) {
|
||||
return asyncRpc({func: 'getRegionRaw', x: x, y: y, w: w, h: h}, 'arraybuffer')
|
||||
.then((data) => new Uint8ClampedArray(data));
|
||||
},
|
||||
async captureMultiAsync(areas) {
|
||||
// XXX: This override the current bind
|
||||
let boundId = await asyncRpc({func: 'bindRegion', x: 0, y: 0, w: alt1.rsWidth, h: alt1.rsHeight}, 'json');
|
||||
let bounds = await Promise.all(Object.keys(areas).map((area) => {
|
||||
let value = areas[area];
|
||||
return asyncRpc({func: 'bindGetRegionRaw', id: boundId, x: value.x, y: value.y, w: value.width, h: value.height}, 'arraybuffer')
|
||||
.then((data) => ({[area]: new Uint8ClampedArray(data)}));
|
||||
}));
|
||||
return bounds.reduce((a, b) => ({...a, ...b}));
|
||||
},
|
||||
bindGetRegionBuffer(id, x, y, w, h) {
|
||||
let data = syncRpc({func: 'bindGetRegionRaw', id: id, x: x, y: y, w: w, h: h});
|
||||
return str2ab(data);
|
||||
},
|
||||
addOCRFont(){
|
||||
},
|
||||
};
|
||||
|
||||
channel.then(function(chan){
|
||||
@@ -287,24 +394,26 @@
|
||||
lastGameActivity = window.performance.now();
|
||||
});
|
||||
|
||||
instance.world_change_signal.connect(function(){
|
||||
alt1.lastWorldHop = new Date().getTime();
|
||||
});
|
||||
|
||||
instance.alt1Signal.connect(function (pos){
|
||||
let mouseX = pos >> 16;
|
||||
let mouseY = pos & 0xFFFF;
|
||||
let mouseRsX = mouseX - instance.gamePositionX;
|
||||
let mouseRsY = mouseY - instance.gamePositionY;
|
||||
let event = {
|
||||
eventName: 'alt1pressed',
|
||||
text: '',
|
||||
mouseAbs: {
|
||||
x: mouseX + instance.gamePositionX,
|
||||
y: mouseY + instance.gamePositionY,
|
||||
},
|
||||
mouseRs: {
|
||||
x: mouseX,
|
||||
y: mouseY,
|
||||
},
|
||||
mouseRs: {
|
||||
x: mouseRsX,
|
||||
y: mouseRsY,
|
||||
},
|
||||
x: mouseRsX,
|
||||
y: mouseRsY,
|
||||
x: mouseX,
|
||||
y: mouseY,
|
||||
rsLinked: true,
|
||||
};
|
||||
emit(event);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING, Dict, Callable, List
|
||||
from typing import TYPE_CHECKING, Dict, Callable, List, NamedTuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from PIL import Image
|
||||
from PySide2.QtCore import (
|
||||
QObject,
|
||||
Slot,
|
||||
@@ -24,44 +22,22 @@ from PySide2.QtGui import QGuiApplication, QCursor, QScreen
|
||||
from PySide2.QtWebChannel import QWebChannel
|
||||
from PySide2.QtWebEngineCore import QWebEngineUrlSchemeHandler, QWebEngineUrlRequestJob
|
||||
|
||||
from runekit.browser.overlay import OverlayApi
|
||||
from runekit.browser.utils import (
|
||||
ApiPermissionDeniedException,
|
||||
image_to_stream,
|
||||
encode_mouse,
|
||||
decode_image,
|
||||
)
|
||||
from runekit.game.instance import ImageType
|
||||
from runekit.ui.tray import tray_icon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from runekit.app.app import App
|
||||
|
||||
TRANSFER_LIMIT = 4_000_000
|
||||
|
||||
|
||||
class ApiPermissionDeniedException(Exception):
|
||||
required_permission: str
|
||||
|
||||
def __init__(self, required_permission: str):
|
||||
super().__init__(
|
||||
"Permission '%s' is needed for this action".format(required_permission)
|
||||
)
|
||||
self.required_permission = required_permission
|
||||
|
||||
|
||||
def _image_to_stream(image: Image, x=0, y=0, width=None, height=None) -> bytes:
|
||||
assert image.mode == "RGB" or image.mode == "RGBA"
|
||||
|
||||
if width is None:
|
||||
width = image.width
|
||||
if height is None:
|
||||
height = image.height
|
||||
|
||||
if width * height * 4 > TRANSFER_LIMIT:
|
||||
return ""
|
||||
|
||||
if image.mode == "RGB":
|
||||
image = image.convert("RGBA")
|
||||
|
||||
r, g, b, a = image.crop((x, y, x + width, y + height)).split()
|
||||
image = Image.merge("RGBA", (b, g, r, a))
|
||||
|
||||
return base64.b64encode(image.tobytes())
|
||||
|
||||
|
||||
def encode_mouse(x: int, y: int) -> int:
|
||||
return (x << 16) | y
|
||||
class BoundedRegion(NamedTuple):
|
||||
image: ImageType
|
||||
|
||||
|
||||
class Alt1Api(QObject):
|
||||
@@ -71,20 +47,31 @@ class Alt1Api(QObject):
|
||||
alt1Signal = Signal(int)
|
||||
|
||||
_screen_info: QRect
|
||||
_bound_regions: List
|
||||
_bound_regions: List[BoundedRegion]
|
||||
_game_active = False
|
||||
_game_position: QRect
|
||||
_game_scaling: float
|
||||
_overlay: OverlayApi
|
||||
|
||||
def __init__(self, app, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.app = app
|
||||
self._bound_regions = []
|
||||
self._overlay = OverlayApi(self, parent=self)
|
||||
self.logger = logging.getLogger(
|
||||
__name__
|
||||
+ "."
|
||||
+ self.__class__.__name__
|
||||
+ "."
|
||||
+ self.app.manifest["appName"]
|
||||
)
|
||||
|
||||
self.rpc_funcs = {
|
||||
"getRegion": self.get_region,
|
||||
"getRegionRaw": self.get_region_raw,
|
||||
"bindRegion": self.bind_region,
|
||||
"bindGetRegion": self.bind_get_region,
|
||||
"bindGetRegionRaw": self.bind_get_region_raw,
|
||||
}
|
||||
|
||||
self._update_screen_info()
|
||||
@@ -92,6 +79,7 @@ class Alt1Api(QObject):
|
||||
self._game_scaling = self.app.game_instance.get_scaling()
|
||||
self._private = Alt1ApiPrivate(self, parent=self)
|
||||
self.app.game_instance.game_activity.connect(self.game_activity_signal)
|
||||
self.app.game_instance.worldChanged.connect(self.world_change_signal)
|
||||
|
||||
poll_timer = QTimer(self)
|
||||
poll_timer.setInterval(250)
|
||||
@@ -113,6 +101,7 @@ class Alt1Api(QObject):
|
||||
self.screen_update_signal.emit()
|
||||
|
||||
screen_update_signal = Signal()
|
||||
|
||||
# endregion
|
||||
|
||||
# region Qt Properties
|
||||
@@ -148,9 +137,9 @@ class Alt1Api(QObject):
|
||||
return 0
|
||||
|
||||
value = QCursor.pos()
|
||||
pos = self.app.game_instance.get_position()
|
||||
pos = self._game_position
|
||||
|
||||
if not pos.contains(value, True):
|
||||
if not pos.contains(value, True) or not self._game_active:
|
||||
# Cursor is out of game
|
||||
return 0
|
||||
|
||||
@@ -203,6 +192,12 @@ class Alt1Api(QObject):
|
||||
gameActive = Property(bool, get_game_active, notify=game_active_change_signal)
|
||||
|
||||
game_activity_signal = Signal()
|
||||
|
||||
def get_world(self):
|
||||
return self.app.game_instance.get_world()
|
||||
|
||||
world_change_signal = Signal()
|
||||
world = Property(bool, get_world, notify=world_change_signal)
|
||||
# endregion
|
||||
|
||||
# region Sync RPC handlers
|
||||
@@ -210,14 +205,25 @@ class Alt1Api(QObject):
|
||||
if not self.app.has_permission("pixel"):
|
||||
raise ApiPermissionDeniedException("pixel")
|
||||
|
||||
return _image_to_stream(self.app.game_instance.grab_region(x, y, w, h))
|
||||
return base64.b64encode(
|
||||
image_to_stream(self.app.game_instance.grab_region(x, y, w, h))
|
||||
)
|
||||
|
||||
def get_region_raw(self, x, y, w, h):
|
||||
if not self.app.has_permission("pixel"):
|
||||
raise ApiPermissionDeniedException("pixel")
|
||||
|
||||
return image_to_stream(
|
||||
self.app.game_instance.grab_region(x, y, w, h), mode="rgba"
|
||||
)
|
||||
|
||||
def bind_region(self, x, y, w, h):
|
||||
if not self.app.has_permission("pixel"):
|
||||
return 0
|
||||
|
||||
# Alt1 only support one region
|
||||
self._bound_regions = [self.app.game_instance.grab_region(x, y, w, h)]
|
||||
bound_data = self.app.game_instance.grab_region(x, y, w, h)
|
||||
self._bound_regions = [BoundedRegion(image=bound_data)]
|
||||
return 1
|
||||
|
||||
def bind_get_region(self, id, x, y, w, h):
|
||||
@@ -230,10 +236,25 @@ class Alt1Api(QObject):
|
||||
try:
|
||||
image = self._bound_regions[id - 1]
|
||||
except IndexError:
|
||||
print("no img index %d" % id)
|
||||
self.logger.warning("bindGetRegion(%d) but image not bound", id)
|
||||
return ""
|
||||
|
||||
return _image_to_stream(image, x, y, w, h)
|
||||
return base64.b64encode(image_to_stream(image.image, x, y, w, h))
|
||||
|
||||
def bind_get_region_raw(self, id, x, y, w, h):
|
||||
if not self.app.has_permission("pixel"):
|
||||
raise ApiPermissionDeniedException("pixel")
|
||||
|
||||
if id == 0:
|
||||
return ""
|
||||
|
||||
try:
|
||||
image = self._bound_regions[id - 1]
|
||||
except IndexError:
|
||||
self.logger.warning("bindGetRegionRaw(%d) but image not bound", id)
|
||||
return ""
|
||||
|
||||
return image_to_stream(image.image, x, y, w, h, mode="rgba")
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -245,6 +266,13 @@ class Alt1Api(QObject):
|
||||
|
||||
self.app.host.notifier.notify(str(text))
|
||||
|
||||
@Slot(str, str, str)
|
||||
def showNotification(self, title, message, icon):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
tray_icon().showMessage(title, message)
|
||||
|
||||
@Slot(str)
|
||||
def identifyAppUrl(self, url):
|
||||
"""Update app manifest from url"""
|
||||
@@ -268,6 +296,97 @@ class Alt1Api(QObject):
|
||||
type_map[type_.toDouble(0)], progress.toDouble(0)
|
||||
)
|
||||
|
||||
@Slot(int, int, int, int, int, int, int, int)
|
||||
def overlayRect(self, call_id, color, x, y, w, h, timeout, line_width):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
self._overlay.enqueue(
|
||||
call_id, "overlay_rect", color, x, y, w, h, timeout, line_width
|
||||
)
|
||||
|
||||
@Slot(int, int, int, int, int, int, int, int)
|
||||
def overlayLine(self, call_id, color, width, x1, y1, x2, y2, timeout):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
self._overlay.enqueue(
|
||||
call_id, "overlay_line", color, width, x1, y1, x2, y2, timeout
|
||||
)
|
||||
|
||||
@Slot(int, int, int, str, int, int)
|
||||
def overlayImage(self, call_id, x, y, img_str, img_width, timeout):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
img_bytes = base64.b64decode(img_str)
|
||||
self._overlay.enqueue(
|
||||
call_id, "overlay_image", img_bytes, x, y, img_width, timeout
|
||||
)
|
||||
|
||||
@Slot(int, str, int, int, int, int, int, str, bool, bool)
|
||||
def overlayTextEx(
|
||||
self, call_id, message, color, size, x, y, timeout, font_name, centered, shadow
|
||||
):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
self._overlay.enqueue(
|
||||
call_id,
|
||||
"overlay_text",
|
||||
message,
|
||||
color,
|
||||
size,
|
||||
x,
|
||||
y,
|
||||
timeout,
|
||||
font_name,
|
||||
centered,
|
||||
shadow,
|
||||
)
|
||||
|
||||
@Slot(int, str)
|
||||
def overlaySetGroup(self, call_id, name):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
self._overlay.enqueue(call_id, "overlay_set_group", name)
|
||||
|
||||
@Slot(int, str)
|
||||
def overlayClearGroup(self, call_id, name):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
self._overlay.enqueue(call_id, "overlay_clear_group", name)
|
||||
|
||||
@Slot(int, str)
|
||||
def overlayFreezeGroup(self, call_id, name):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
self._overlay.enqueue(call_id, "overlay_freeze_group", name)
|
||||
|
||||
@Slot(int, str)
|
||||
def overlayContinueGroup(self, call_id, name):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
self._overlay.enqueue(call_id, "overlay_continue_group", name)
|
||||
|
||||
@Slot(int, str)
|
||||
def overlayRefreshGroup(self, call_id, name):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
self._overlay.enqueue(call_id, "overlay_refresh_group", name)
|
||||
|
||||
@Slot(int, str, int)
|
||||
def overlaySetGroupZIndex(self, call_id, group, zIndex):
|
||||
if not self.app.has_permission("overlay"):
|
||||
raise ApiPermissionDeniedException("overlay")
|
||||
|
||||
self._overlay.enqueue(call_id, "overlay_set_group_z", group, zIndex)
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import collections
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import List, Dict, Tuple, Optional, TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import QObject, QTimer, Qt
|
||||
from PySide2.QtGui import QFont, QPen, QImage, QPixmap
|
||||
from PySide2.QtWidgets import (
|
||||
QGraphicsItem,
|
||||
QGraphicsDropShadowEffect,
|
||||
QGraphicsTextItem,
|
||||
QGraphicsRectItem,
|
||||
QGraphicsItemGroup,
|
||||
QGraphicsLineItem,
|
||||
QGraphicsPixmapItem,
|
||||
)
|
||||
|
||||
from .utils import decode_color
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .api import Alt1Api
|
||||
|
||||
|
||||
def barrier(f):
|
||||
"""Mark f as sync barrier - all preceeding calls must be finished.
|
||||
Only affect calls from scheduler"""
|
||||
f.barrier = True
|
||||
return f
|
||||
|
||||
|
||||
def ensure_overlay(f):
|
||||
def out(self: "OverlayApi", *args, **kwargs):
|
||||
if not self.overlay_area:
|
||||
return
|
||||
|
||||
return f(self, *args, **kwargs)
|
||||
|
||||
return functools.update_wrapper(out, f)
|
||||
|
||||
|
||||
class OverlayApi(QObject):
|
||||
# TODO: I think this could be implemented by QGraphicsItemGroup, maybe even more performant
|
||||
current_group = ""
|
||||
groups: Dict[str, List[QGraphicsItem]]
|
||||
frozen_group: Dict[str, QGraphicsItemGroup]
|
||||
queue: List[Tuple[int, str, List]]
|
||||
last_call_id: Optional[int] = None
|
||||
overlay_area: Optional[QGraphicsItem] = None
|
||||
|
||||
def __init__(self, base_api: "Alt1Api", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.api = base_api
|
||||
self.reset()
|
||||
|
||||
try:
|
||||
self.overlay_area = self.api.app.game_instance.get_overlay_area()
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
@ensure_overlay
|
||||
def enqueue(self, call_id, command, *args):
|
||||
# XXX: command is NOT validated. Must be from secure source
|
||||
assert command[0] != "_"
|
||||
|
||||
if call_id == 0:
|
||||
self.logger.info("Call ID reset")
|
||||
self.reset()
|
||||
|
||||
self.queue.append((call_id, command, args))
|
||||
self.queue.sort(key=lambda x: x[0])
|
||||
# self.logger.debug("%d %s %s", call_id, command, repr(args))
|
||||
|
||||
QTimer.singleShot(0, self.process_queue)
|
||||
|
||||
def process_queue(self):
|
||||
while len(self.queue) > 0:
|
||||
head = self.queue[0]
|
||||
f = getattr(self, head[1])
|
||||
if (
|
||||
hasattr(f, "barrier")
|
||||
and self.last_call_id is not None
|
||||
and head[0] != self.last_call_id + 1
|
||||
):
|
||||
return
|
||||
|
||||
head = self.queue.pop(0)
|
||||
self.last_call_id = head[0]
|
||||
try:
|
||||
f(*head[2])
|
||||
except:
|
||||
self.logger.error(
|
||||
"API call exception #%d %s(%s)",
|
||||
head[0],
|
||||
head[1],
|
||||
repr(head[2]),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _finalize_gfx(
|
||||
self, gfx: QGraphicsItem, timeout: int, group: Optional[str] = None
|
||||
):
|
||||
timeout = min(20000, max(timeout, 1))
|
||||
|
||||
if group is None:
|
||||
group = self.current_group
|
||||
|
||||
if group in self.frozen_group:
|
||||
self.frozen_group[group].addToGroup(gfx)
|
||||
else:
|
||||
self.groups[group].append(gfx)
|
||||
gfx.setParentItem(self.overlay_area)
|
||||
|
||||
def hide():
|
||||
try:
|
||||
gfx.scene().removeItem(gfx)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.groups[group].remove(gfx)
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
QTimer.singleShot(timeout, hide)
|
||||
|
||||
def reset(self):
|
||||
if hasattr(self, "groups"):
|
||||
for item in self.groups.values():
|
||||
item.scene().removeItem(item)
|
||||
|
||||
self.groups = collections.defaultdict(list)
|
||||
self.frozen_group = {}
|
||||
self.queue = []
|
||||
self.last_call_id = None
|
||||
|
||||
@barrier
|
||||
@ensure_overlay
|
||||
def overlay_set_group(self, name: str):
|
||||
self.current_group = name
|
||||
|
||||
@barrier
|
||||
@ensure_overlay
|
||||
def overlay_clear_group(self, name: str):
|
||||
for item in self.groups.get(name, []):
|
||||
item.scene().removeItem(item)
|
||||
|
||||
try:
|
||||
del self.groups[name]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
@barrier
|
||||
@ensure_overlay
|
||||
def overlay_freeze_group(self, name: str):
|
||||
if name in self.frozen_group:
|
||||
return
|
||||
|
||||
self.frozen_group[name] = QGraphicsItemGroup()
|
||||
|
||||
@barrier
|
||||
@ensure_overlay
|
||||
def overlay_continue_group(self, name: str):
|
||||
if name not in self.frozen_group:
|
||||
return
|
||||
|
||||
gfx = self.frozen_group[name]
|
||||
del self.frozen_group[name]
|
||||
self._finalize_gfx(gfx, 20000, group=name)
|
||||
|
||||
@barrier
|
||||
@ensure_overlay
|
||||
def overlay_refresh_group(self, name: str):
|
||||
if name not in self.frozen_group:
|
||||
return
|
||||
|
||||
self.overlay_continue_group(name)
|
||||
self.overlay_freeze_group(name)
|
||||
|
||||
@ensure_overlay
|
||||
def overlay_rect(
|
||||
self, color: int, x: int, y: int, w: int, h: int, timeout: int, line_width: int
|
||||
):
|
||||
pen = QPen(decode_color(color))
|
||||
pen.setWidthF(max(1.0, line_width / 10))
|
||||
|
||||
gfx = QGraphicsRectItem(x, y, w, h)
|
||||
gfx.setPen(pen)
|
||||
|
||||
self._finalize_gfx(gfx, timeout)
|
||||
|
||||
@ensure_overlay
|
||||
def overlay_line(
|
||||
self,
|
||||
color: int,
|
||||
line_width: int,
|
||||
x1: int,
|
||||
y1: int,
|
||||
x2: int,
|
||||
y2: int,
|
||||
timeout: int,
|
||||
):
|
||||
pen = QPen(decode_color(color))
|
||||
pen.setWidthF(max(1.0, line_width / 10))
|
||||
|
||||
gfx = QGraphicsLineItem(x1, y1, x2, y2)
|
||||
gfx.setPen(pen)
|
||||
|
||||
self._finalize_gfx(gfx, timeout)
|
||||
|
||||
@ensure_overlay
|
||||
def overlay_text(
|
||||
self,
|
||||
message: str,
|
||||
color: int,
|
||||
size: int,
|
||||
x: int,
|
||||
y: int,
|
||||
timeout: int,
|
||||
font_name: str,
|
||||
centered: bool,
|
||||
shadow: bool,
|
||||
):
|
||||
gfx = QGraphicsTextItem(message)
|
||||
gfx.setDefaultTextColor(decode_color(color))
|
||||
|
||||
font = QFont(font_name, min(50, size))
|
||||
font.setStyleHint(QFont.SansSerif)
|
||||
gfx.setFont(font)
|
||||
|
||||
if shadow:
|
||||
effect = QGraphicsDropShadowEffect(gfx)
|
||||
effect.setBlurRadius(0)
|
||||
effect.setColor(Qt.GlobalColor.black)
|
||||
effect.setOffset(1, 1)
|
||||
gfx.setGraphicsEffect(effect)
|
||||
|
||||
if centered:
|
||||
# The provided x, y is at the center of the text
|
||||
bound = gfx.boundingRect()
|
||||
gfx.setPos(x - (bound.width() / 2), y - (bound.height() / 2))
|
||||
else:
|
||||
gfx.setPos(x, y)
|
||||
|
||||
self._finalize_gfx(gfx, timeout)
|
||||
|
||||
@ensure_overlay
|
||||
def overlay_image(self, img: bytes, x: int, y: int, width: int, timeout: int):
|
||||
img = self.get_qimage(img, width)
|
||||
|
||||
gfx = QGraphicsPixmapItem(QPixmap.fromImage(img))
|
||||
gfx.setPos(x, y)
|
||||
|
||||
self._finalize_gfx(gfx, timeout)
|
||||
|
||||
@ensure_overlay
|
||||
def overlay_set_group_z(self, name: str, z_index: int):
|
||||
for item in self.groups.get(name, []):
|
||||
item.setZValue(z_index)
|
||||
|
||||
@functools.lru_cache(100)
|
||||
def get_qimage(self, img: bytes, width: int):
|
||||
height = len(img) / width / 4
|
||||
return QImage(img, width, height, QImage.Format_ARGB32)
|
||||
@@ -1,9 +1,12 @@
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import QFile, QIODevice
|
||||
from PySide2.QtWebEngineWidgets import QWebEngineProfile, QWebEngineScript
|
||||
from PySide2.QtWebEngineWidgets import (
|
||||
QWebEngineProfile,
|
||||
QWebEngineScript,
|
||||
QWebEngineSettings,
|
||||
)
|
||||
|
||||
from runekit.browser.api import RuneKitSchemeHandler
|
||||
from runekit.browser.scheme import RuneKitScheme
|
||||
@@ -11,25 +14,23 @@ from runekit.browser.scheme import RuneKitScheme
|
||||
if TYPE_CHECKING:
|
||||
from runekit.app import App
|
||||
|
||||
script_file = Path(__file__).parent / "alt1.js"
|
||||
|
||||
with script_file.open() as fp:
|
||||
src = fp.read()
|
||||
|
||||
_added_qwebchannel = False
|
||||
|
||||
|
||||
class WebProfile(QWebEngineProfile):
|
||||
rpc_secret: str
|
||||
app: "App"
|
||||
|
||||
def __init__(self, app=None, parent=None):
|
||||
# TODO: Persist
|
||||
super().__init__(parent)
|
||||
super().__init__("app", parent=parent)
|
||||
self.app = app
|
||||
self.rpc_secret = secrets.token_urlsafe(64)
|
||||
self._insert_scheme_handlers()
|
||||
self._insert_alt1_api()
|
||||
self.settings().setAttribute(
|
||||
QWebEngineSettings.PlaybackRequiresUserGesture, False
|
||||
)
|
||||
self.settings().setAttribute(QWebEngineSettings.PdfViewerEnabled, False)
|
||||
self.settings().setAttribute(QWebEngineSettings.ScreenCaptureEnabled, False)
|
||||
self.settings().setAttribute(QWebEngineSettings.AutoLoadIconsForPage, False)
|
||||
|
||||
def _insert_scheme_handlers(self):
|
||||
self.installUrlSchemeHandler(
|
||||
@@ -42,23 +43,25 @@ class WebProfile(QWebEngineProfile):
|
||||
)
|
||||
|
||||
def _insert_alt1_api(self):
|
||||
global _added_qwebchannel, src
|
||||
qwc_file = QFile(":/qtwebchannel/qwebchannel.js", parent=self)
|
||||
if not qwc_file.open(QIODevice.ReadOnly):
|
||||
raise IOError
|
||||
src = bytes(qwc_file.readAll()).decode("utf8")
|
||||
qwc_file.close()
|
||||
|
||||
if not _added_qwebchannel:
|
||||
qwc_file = QFile(":/qtwebchannel/qwebchannel.js", parent=self)
|
||||
if not qwc_file.open(QIODevice.ReadOnly):
|
||||
raise IOError
|
||||
qwc_src = bytes(qwc_file.readAll()).decode("utf8")
|
||||
qwc_file.close()
|
||||
src += "\n;;\n"
|
||||
|
||||
src = qwc_src + "\n;;\n" + src
|
||||
|
||||
_added_qwebchannel = True
|
||||
alt1_file = QFile(":/runekit/browser/alt1.js", parent=self)
|
||||
if not alt1_file.open(QIODevice.ReadOnly):
|
||||
raise IOError
|
||||
src += bytes(alt1_file.readAll()).decode("utf8")
|
||||
alt1_file.close()
|
||||
|
||||
script = QWebEngineScript()
|
||||
script.setName("alt1.js")
|
||||
script.setWorldId(QWebEngineScript.MainWorld)
|
||||
script.setInjectionPoint(QWebEngineScript.DocumentCreation)
|
||||
script.setSourceCode(src.replace("%%RPC_TOKEN%%", self.rpc_secret))
|
||||
script.setRunsOnSubFrames(True)
|
||||
|
||||
self.scripts().insert(script)
|
||||
@@ -0,0 +1,106 @@
|
||||
import base64
|
||||
from typing import TypeVar
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from PySide2.QtGui import QColor
|
||||
|
||||
from runekit.game.instance import ImageType
|
||||
from runekit.image.np_utils import np_crop
|
||||
|
||||
TRANSFER_LIMIT = 4_000_000
|
||||
|
||||
|
||||
class ApiPermissionDeniedException(Exception):
|
||||
required_permission: str
|
||||
|
||||
def __init__(self, required_permission: str):
|
||||
super().__init__(
|
||||
"Permission '%s' is needed for this action".format(required_permission)
|
||||
)
|
||||
self.required_permission = required_permission
|
||||
|
||||
|
||||
ImgTypeG = TypeVar("T", np.ndarray, Image.Image)
|
||||
|
||||
|
||||
def ensure_image_rgba(image: ImgTypeG) -> ImgTypeG:
|
||||
# XXX: This function is not idempotent!
|
||||
if isinstance(image, np.ndarray):
|
||||
return image[:, :, [2, 1, 0, 3]]
|
||||
else:
|
||||
if image.mode == "RGB":
|
||||
image = image.convert("RGBA")
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def ensure_image_bgra(image: ImgTypeG) -> ImgTypeG:
|
||||
# XXX: This function is not idempotent!
|
||||
if isinstance(image, np.ndarray):
|
||||
return image
|
||||
else:
|
||||
if image.mode == "RGB":
|
||||
image = image.convert("RGBA")
|
||||
|
||||
r, g, b, a = image.split()
|
||||
image = Image.merge("RGBA", (b, g, r, a))
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def ensure_image(image: ImgTypeG, mode: str) -> ImgTypeG:
|
||||
if mode == "rgba":
|
||||
return ensure_image_rgba(image)
|
||||
elif mode == "bgra":
|
||||
return ensure_image_bgra(image)
|
||||
else:
|
||||
raise ValueError("invalid mode")
|
||||
|
||||
|
||||
def image_to_stream(
|
||||
image: ImageType,
|
||||
x=0,
|
||||
y=0,
|
||||
width=None,
|
||||
height=None,
|
||||
mode="bgra",
|
||||
) -> bytes:
|
||||
if isinstance(image, np.ndarray):
|
||||
out = np_crop(image, x, y, width, height)
|
||||
out = ensure_image(out, mode).tobytes()
|
||||
else:
|
||||
assert image.mode == "RGBA"
|
||||
|
||||
if width is None:
|
||||
width = image.width
|
||||
if height is None:
|
||||
height = image.height
|
||||
|
||||
if width * height * 4 > TRANSFER_LIMIT:
|
||||
return ""
|
||||
|
||||
image = image.crop((x, y, x + width, y + height))
|
||||
|
||||
out = ensure_image(image, mode).tobytes()
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def encode_mouse(x: int, y: int) -> int:
|
||||
return (x << 16) | y
|
||||
|
||||
|
||||
def decode_color(color: int) -> QColor:
|
||||
r = (color >> 16) & 0xFF
|
||||
g = (color >> 8) & 0xFF
|
||||
b = (color >> 0) & 0xFF
|
||||
a = (color >> 24) & 0xFF
|
||||
return QColor.fromRgb(r, g, b, a)
|
||||
|
||||
|
||||
def decode_image(img: str, width: int) -> np.ndarray:
|
||||
img = base64.b64decode(img)
|
||||
img = np.frombuffer(img, "<B")
|
||||
img.shape = (-1, width, 4)
|
||||
return img
|
||||
@@ -1,15 +1,21 @@
|
||||
import abc
|
||||
import dataclasses
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import QObject, Signal, Property, QRect, Slot
|
||||
from typing import TYPE_CHECKING, Literal, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from PySide2.QtCore import QObject, Signal, Property, QRect, Slot
|
||||
from PySide2.QtGui import QWindow
|
||||
from PySide2.QtWidgets import QGraphicsItem
|
||||
|
||||
from runekit.image.np_utils import np_crop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import GameManager
|
||||
|
||||
PROGRESS_TYPE = Literal["RESET", "IN_PROGRESS", "ERROR", "LOADING", "PAUSED"]
|
||||
ImageType = Union[np.ndarray, Image.Image]
|
||||
|
||||
|
||||
class GameInstance(QObject):
|
||||
refresh_rate = 1000
|
||||
@@ -34,6 +40,7 @@ class GameInstance(QObject):
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_position(self) -> QRect:
|
||||
"""Return the game window position, without space used by window decoration"""
|
||||
...
|
||||
|
||||
positionChanged = Signal(QRect)
|
||||
@@ -54,17 +61,33 @@ class GameInstance(QObject):
|
||||
focused = Property(bool, is_focused, notify=focusChanged)
|
||||
|
||||
@abc.abstractmethod
|
||||
def grab_game(self) -> Image:
|
||||
def get_world(self) -> Optional[int]:
|
||||
...
|
||||
|
||||
worldChanged = Signal(int)
|
||||
world = Property(int, get_world, notify=worldChanged)
|
||||
|
||||
@abc.abstractmethod
|
||||
def grab_game(self) -> ImageType:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def grab_desktop(self, x: int, y: int, w: int, h: int) -> Image:
|
||||
def grab_desktop(self, x: int, y: int, w: int, h: int) -> ImageType:
|
||||
...
|
||||
|
||||
def grab_region(self, x: int, y: int, w: int, h: int) -> Image:
|
||||
def grab_region(self, x: int, y: int, w: int, h: int) -> ImageType:
|
||||
image = self.grab_game()
|
||||
return image.crop((x, y, x + w, y + h))
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_taskbar_progress(self, type, progress):
|
||||
...
|
||||
if isinstance(image, np.ndarray):
|
||||
return np_crop(image, x, y, w, h)
|
||||
else:
|
||||
return image.crop((x, y, x + w, y + h))
|
||||
|
||||
def set_taskbar_progress(self, type_: PROGRESS_TYPE, progress: float):
|
||||
pass
|
||||
|
||||
def embed_window(self, window: QWindow):
|
||||
pass
|
||||
|
||||
def get_overlay_area(self) -> QGraphicsItem:
|
||||
raise NotImplementedError
|
||||
@@ -1,5 +1,5 @@
|
||||
import abc
|
||||
from typing import List
|
||||
from typing import List, Union
|
||||
|
||||
from PySide2.QtCore import QObject, Property, Signal
|
||||
|
||||
@@ -12,7 +12,15 @@ class GameManager(QObject):
|
||||
"""Return a list of active game instance. The instances returned should be stable (same instance for all invocation)"""
|
||||
...
|
||||
|
||||
instance_added = Signal()
|
||||
instance_removed = Signal()
|
||||
@abc.abstractmethod
|
||||
def get_active_instance(self) -> Union[GameInstance, None]:
|
||||
...
|
||||
|
||||
instance_added = Signal(GameInstance)
|
||||
instance_removed = Signal(GameInstance)
|
||||
instance_changed = Signal()
|
||||
instances = Property(list, get_instances, notify=instance_changed)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the GameManager and any GameInstances"""
|
||||
pass
|
||||
@@ -0,0 +1,115 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Callable, Tuple, Dict
|
||||
|
||||
import numpy as np
|
||||
from PySide2.QtCore import Qt, QRect, QTimer
|
||||
from PySide2.QtGui import QGuiApplication, QPen
|
||||
from PySide2.QtWidgets import (
|
||||
QMainWindow,
|
||||
QGraphicsView,
|
||||
QGraphicsScene,
|
||||
QGraphicsItem,
|
||||
QGraphicsRectItem,
|
||||
)
|
||||
|
||||
from .qt import qpixmap_to_np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .instance import GameInstance
|
||||
|
||||
|
||||
class DesktopWideOverlay(QMainWindow):
|
||||
_instances: Dict[int, QGraphicsItem]
|
||||
view: QGraphicsView
|
||||
scene: QGraphicsScene
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
flags=Qt.Widget
|
||||
| Qt.FramelessWindowHint
|
||||
| Qt.BypassWindowManagerHint
|
||||
| Qt.WindowTransparentForInput
|
||||
| Qt.WindowStaysOnTopHint
|
||||
)
|
||||
self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
|
||||
self.setAttribute(Qt.WA_NoSystemBackground, True)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose, True)
|
||||
self.setStyleSheet("background: transparent")
|
||||
self._instances = {}
|
||||
|
||||
virtual_screen = QRect(0, 0, 0, 0)
|
||||
|
||||
for screen in QGuiApplication.screens():
|
||||
# TODO: Handle screen change
|
||||
geom = screen.virtualGeometry()
|
||||
virtual_screen = virtual_screen.united(geom)
|
||||
|
||||
self.scene = QGraphicsScene(
|
||||
0, 0, virtual_screen.width(), virtual_screen.height(), parent=self
|
||||
)
|
||||
|
||||
self.view = QGraphicsView(self.scene, self)
|
||||
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.view.setStyleSheet("background: transparent")
|
||||
self.view.setGeometry(0, 0, virtual_screen.width(), virtual_screen.height())
|
||||
self.view.setInteractive(False)
|
||||
|
||||
self.transparent_pen = QPen()
|
||||
self.transparent_pen.setBrush(Qt.NoBrush)
|
||||
|
||||
self.setGeometry(virtual_screen)
|
||||
|
||||
def add_instance(
|
||||
self, instance: "GameInstance"
|
||||
) -> Tuple[QGraphicsItem, Callable[[], None]]:
|
||||
"""Add instance to manage, return a disconnect function and the canvas"""
|
||||
positionChanged = lambda rect: self.on_instance_moved(instance, rect)
|
||||
instance.positionChanged.connect(positionChanged)
|
||||
|
||||
focusChanged = lambda focus: self.on_instance_focus_change(instance, focus)
|
||||
instance.focusChanged.connect(focusChanged)
|
||||
|
||||
instance_pos = instance.get_position()
|
||||
gfx = QGraphicsRectItem(rect=instance_pos)
|
||||
gfx.setPen(self.transparent_pen)
|
||||
gfx.setPos(instance_pos.x(), instance_pos.y())
|
||||
self.scene.addItem(gfx)
|
||||
self._instances[instance.wid] = gfx
|
||||
|
||||
def disconnect():
|
||||
gfx.hide()
|
||||
self.scene.removeItem(gfx)
|
||||
instance.positionChanged.disconnect(positionChanged)
|
||||
instance.focusChanged.disconnect(focusChanged)
|
||||
|
||||
return gfx, disconnect
|
||||
|
||||
def on_instance_focus_change(self, instance, focus):
|
||||
# self._instances[instance.wid].setVisible(focus)
|
||||
pass
|
||||
|
||||
def on_instance_moved(self, instance, pos: QRect):
|
||||
rect = self._instances[instance.wid]
|
||||
rect.setRect(0, 0, pos.width(), pos.height())
|
||||
rect.setPos(pos.x(), pos.y())
|
||||
|
||||
def check_compatibility(self):
|
||||
QTimer.singleShot(300, self._check_compatibility)
|
||||
|
||||
def _check_compatibility(self):
|
||||
# If we cause black screen then hide ourself out of shame...
|
||||
screenshot = QGuiApplication.primaryScreen().grabWindow(0)
|
||||
image = qpixmap_to_np(screenshot)
|
||||
black_pixels = np.count_nonzero(np.all(image[:, :, :3] == [0, 0, 0], axis=-1))
|
||||
total_pixels = len(image[:, :, 0].flatten())
|
||||
black_percent = black_pixels / total_pixels
|
||||
|
||||
self.logger.debug("Screen black ratio %.2f%%", black_percent * 100)
|
||||
if black_percent > 0.95:
|
||||
self.logger.warning(
|
||||
"Detected black screen at %.2f%%. Disabling overlay",
|
||||
black_percent * 100,
|
||||
)
|
||||
self.hide()
|
||||
@@ -0,0 +1,94 @@
|
||||
import re
|
||||
import socket
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
import psutil
|
||||
from PySide2.QtCore import QTimer, Slot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
mode = None
|
||||
|
||||
MODE_PROCESS = 1
|
||||
MODE_GLOBAL = 2
|
||||
|
||||
|
||||
class PsUtilBaseMixin:
|
||||
pid: int
|
||||
|
||||
|
||||
class PsUtilNetStat(PsUtilBaseMixin):
|
||||
__cached_rdns: dict[str, str]
|
||||
__last_world = None
|
||||
world_regex = re.compile(r"^world([0-9]+)\.runescape\.com$")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__cached_rdns = {}
|
||||
self.__world_timer = QTimer(self)
|
||||
self.__world_timer.start(60_000)
|
||||
self.__world_timer.timeout.connect(self.__update_world)
|
||||
|
||||
def get_world(self):
|
||||
if self.__last_world is None:
|
||||
self.__last_world = self.fetch_world()
|
||||
|
||||
return self.__last_world
|
||||
|
||||
def fetch_world(self) -> Optional[int]:
|
||||
try:
|
||||
addrs = self.__get_connections()
|
||||
except psutil.AccessDenied:
|
||||
logger.warning("Cannot get connections", exc_info=True)
|
||||
return None
|
||||
|
||||
for conn in addrs:
|
||||
if conn.status != psutil.CONN_ESTABLISHED:
|
||||
continue
|
||||
|
||||
dns_name = self._rdns(conn.raddr.ip)
|
||||
matched_name = self.world_regex.match(dns_name)
|
||||
if not matched_name:
|
||||
continue
|
||||
|
||||
world = int(matched_name.group(1))
|
||||
return world
|
||||
|
||||
def __get_connections(self):
|
||||
global mode
|
||||
if mode is not None:
|
||||
return self.__get_connections_mode(mode)
|
||||
|
||||
last_exc = None
|
||||
for method in [MODE_PROCESS, MODE_GLOBAL]:
|
||||
try:
|
||||
out = self.__get_connections_mode(method)
|
||||
mode = method
|
||||
return out
|
||||
except psutil.AccessDenied as e:
|
||||
last_exc = e
|
||||
|
||||
raise last_exc
|
||||
|
||||
def __get_connections_mode(self, mode_: int):
|
||||
if mode_ is MODE_PROCESS:
|
||||
return psutil.Process(self.pid).connections()
|
||||
elif mode_ is MODE_GLOBAL:
|
||||
return [item for item in psutil.net_connections() if item.pid == self.pid]
|
||||
else:
|
||||
raise ValueError(f"Unknown mode {mode_}")
|
||||
|
||||
@Slot()
|
||||
def __update_world(self):
|
||||
new_world = self.fetch_world()
|
||||
if new_world != self.__last_world:
|
||||
logger.info("World hopped from %d to %d", self.__last_world, new_world)
|
||||
self.__last_world = new_world
|
||||
self.worldChanged.emit(new_world)
|
||||
|
||||
def _rdns(self, ip: str) -> Optional[str]:
|
||||
if ip in self.__cached_rdns:
|
||||
return self.__cached_rdns[ip]
|
||||
|
||||
self.__cached_rdns[ip] = socket.getnameinfo((ip, 0), 0)[0]
|
||||
return self.__cached_rdns[ip]
|
||||
@@ -1,13 +1,37 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from PIL import Image
|
||||
from PySide2.QtGui import QGuiApplication, QWindow
|
||||
import numpy as np
|
||||
from PySide2.QtCore import QBuffer, QIODevice
|
||||
from PySide2.QtGui import QGuiApplication, QWindow, QPixmap
|
||||
import cv2
|
||||
|
||||
from runekit.image.np_utils import np_save_image
|
||||
|
||||
_debug_dump_file = False
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def qpixmap_to_np(im: QPixmap) -> np.ndarray:
|
||||
# from PIL.ImageQt.fromqimage
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.ReadWrite)
|
||||
if im.hasAlphaChannel():
|
||||
im.save(buffer, "png")
|
||||
else:
|
||||
im.save(buffer, "ppm")
|
||||
|
||||
npbuf = np.frombuffer(buffer.data(), "<B")
|
||||
buffer.close()
|
||||
|
||||
out = cv2.imdecode(npbuf, cv2.IMREAD_UNCHANGED)
|
||||
if out.shape[2] == 3:
|
||||
# Add alpha channel
|
||||
out = np.pad(out, ((0, 0), (0, 0), (0, 1)), constant_values=0xFF)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class QtBaseMixin:
|
||||
qwindow: QWindow
|
||||
|
||||
@@ -18,29 +42,34 @@ class QtGrabMixin(QtBaseMixin):
|
||||
__game_last_grab = 0.0
|
||||
__game_last_image = None
|
||||
|
||||
def grab_game(self) -> Image:
|
||||
def grab_game(self) -> np.ndarray:
|
||||
if (time.monotonic() - self.__game_last_grab) * 1000 < self.refresh_rate:
|
||||
return self.__game_last_image
|
||||
|
||||
screen = QGuiApplication.primaryScreen()
|
||||
pixmap = screen.grabWindow(self.qwindow.winId())
|
||||
image = Image.fromqpixmap(pixmap)
|
||||
image = qpixmap_to_np(pixmap)
|
||||
|
||||
if _debug_dump_file:
|
||||
logger.debug("dumping file")
|
||||
pixmap.save("/tmp/qtshot.bmp", None, 100)
|
||||
np_save_image(image, "/tmp/qtshot.bmp")
|
||||
|
||||
self.__game_last_image = image
|
||||
self.__game_last_grab = time.monotonic()
|
||||
return self.__game_last_image
|
||||
|
||||
def grab_desktop(self, x, y, w, h) -> Image:
|
||||
def grab_desktop(self, x, y, w, h) -> np.ndarray:
|
||||
screen = QGuiApplication.primaryScreen()
|
||||
pixmap = screen.grabWindow(0, x, y, w, h)
|
||||
image = Image.fromqpixmap(pixmap)
|
||||
image = qpixmap_to_np(pixmap)
|
||||
|
||||
if _debug_dump_file:
|
||||
logger.debug("dumping file")
|
||||
pixmap.save("/tmp/qtshot.bmp", None, 100)
|
||||
np_save_image(image, "/tmp/qtshot.bmp")
|
||||
|
||||
return image
|
||||
|
||||
|
||||
class QtEmbedMixin(QtBaseMixin):
|
||||
def embed_window(self, window: QWindow):
|
||||
window.setParent(self.qwindow)
|
||||
@@ -1,6 +1,7 @@
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import Quartz
|
||||
import objc
|
||||
@@ -8,8 +9,13 @@ from PIL import Image
|
||||
from PySide2.QtCore import QRect
|
||||
from PySide2.QtGui import QGuiApplication
|
||||
import ApplicationServices
|
||||
from PySide2.QtWidgets import QGraphicsItem
|
||||
|
||||
from ..instance import GameInstance
|
||||
from ..psutil_mixins import PsUtilNetStat
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import QuartzGameManager
|
||||
|
||||
_debug_dump_file = False
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -71,13 +77,14 @@ def on_ax_event(observer, element, notification, ptr):
|
||||
self.logger.warning("Got unknown AX event %s", notification)
|
||||
|
||||
|
||||
class QuartzGameInstance(GameInstance):
|
||||
class QuartzGameInstance(PsUtilNetStat, GameInstance):
|
||||
_is_active = False
|
||||
overlay: QGraphicsItem
|
||||
|
||||
__game_last_grab = 0.0
|
||||
__game_last_image = None
|
||||
|
||||
def __init__(self, manager, wid, pid, **kwargs):
|
||||
def __init__(self, manager: "QuartzGameManager", wid, pid, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.manager = manager
|
||||
self.wid = wid
|
||||
@@ -87,6 +94,7 @@ class QuartzGameInstance(GameInstance):
|
||||
|
||||
self._setup_observer()
|
||||
self.update_is_active()
|
||||
self.overlay, self._overlay_disconnect = self.manager.overlay.add_instance(self)
|
||||
|
||||
def _setup_observer(self):
|
||||
self._ax_element = ApplicationServices.AXUIElementCreateApplication(self.pid)
|
||||
@@ -123,6 +131,7 @@ class QuartzGameInstance(GameInstance):
|
||||
)
|
||||
|
||||
objc.context.unregister(self)
|
||||
self._overlay_disconnect()
|
||||
|
||||
def get_position(self) -> QRect:
|
||||
# The docs say this API is expensive...
|
||||
@@ -155,6 +164,10 @@ class QuartzGameInstance(GameInstance):
|
||||
[self.wid],
|
||||
Quartz.kCGWindowImageBoundsIgnoreFraming,
|
||||
)
|
||||
if not imgref:
|
||||
self.manager.request_accessibility_popup.emit()
|
||||
raise NoCapturePermission
|
||||
|
||||
out = cgimageref_to_image(imgref)
|
||||
scale = self.get_scaling()
|
||||
if scale > 1:
|
||||
@@ -176,8 +189,8 @@ class QuartzGameInstance(GameInstance):
|
||||
out = cgimageref_to_image(imgref)
|
||||
return out.resize((w, h), Image.NEAREST)
|
||||
|
||||
def set_taskbar_progress(self, type, progress):
|
||||
pass
|
||||
def get_overlay_area(self) -> QGraphicsItem:
|
||||
return self.overlay
|
||||
|
||||
|
||||
class AXAPIError(Exception):
|
||||
@@ -188,6 +201,7 @@ class AXAPIError(Exception):
|
||||
ApplicationServices.kAXErrorNotificationAlreadyRegistered: "The notification has already been registered",
|
||||
ApplicationServices.kAXErrorCannotComplete: "The function cannot complete because messaging has failed in some way.",
|
||||
ApplicationServices.kAXErrorFailure: "There is some sort of system memory failure.",
|
||||
ApplicationServices.kAXErrorAPIDisabled: "Assistive applications are not enabled in System Preferences.",
|
||||
}
|
||||
|
||||
def __init__(self, code):
|
||||
@@ -195,3 +209,8 @@ class AXAPIError(Exception):
|
||||
raise ValueError("Success")
|
||||
|
||||
super().__init__(self.mapping.get(code, f"API Error: {code}"))
|
||||
|
||||
|
||||
class NoCapturePermission(Exception):
|
||||
def __init__(self):
|
||||
super().__init__("Screen Recording permission is not allowed")
|
||||
@@ -1,12 +1,15 @@
|
||||
import time
|
||||
from functools import reduce
|
||||
from typing import List, Dict, Optional
|
||||
from typing import List, Dict, Optional, Union
|
||||
|
||||
import Quartz
|
||||
from PySide2.QtCore import QTimer
|
||||
import ApplicationServices
|
||||
from PySide2.QtCore import QTimer, Signal, Slot
|
||||
from PySide2.QtGui import QDesktopServices
|
||||
from PySide2.QtWidgets import QMessageBox
|
||||
|
||||
from .instance import QuartzGameInstance
|
||||
from runekit.game.overlay import DesktopWideOverlay
|
||||
from ..instance import GameInstance
|
||||
from ..manager import GameManager
|
||||
|
||||
@@ -15,12 +18,26 @@ has_prompted_accessibility = False
|
||||
|
||||
class QuartzGameManager(GameManager):
|
||||
_instances: Dict[int, GameInstance]
|
||||
overlay: DesktopWideOverlay
|
||||
|
||||
request_accessibility_popup = Signal()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._instances = {}
|
||||
self.request_accessibility_popup.connect(self.accessibility_popup)
|
||||
self._setup_tap()
|
||||
|
||||
ApplicationServices.AXIsProcessTrustedWithOptions(
|
||||
{
|
||||
ApplicationServices.kAXTrustedCheckOptionPrompt: True,
|
||||
}
|
||||
)
|
||||
while not ApplicationServices.AXIsProcessTrusted():
|
||||
time.sleep(0.1)
|
||||
|
||||
self._setup_overlay()
|
||||
|
||||
def _setup_tap(self):
|
||||
events = [
|
||||
Quartz.kCGEventLeftMouseDown,
|
||||
@@ -42,6 +59,24 @@ class QuartzGameManager(GameManager):
|
||||
Quartz.CFRunLoopGetCurrent(), source, Quartz.kCFRunLoopCommonModes
|
||||
)
|
||||
|
||||
def _setup_overlay(self):
|
||||
self.overlay = DesktopWideOverlay()
|
||||
|
||||
def start():
|
||||
self.overlay.show()
|
||||
self.overlay.check_compatibility()
|
||||
|
||||
# Seems like QGraphicsView has a delay before applying stylesheet
|
||||
# Put some delay to allow it to initialize and not flash
|
||||
QTimer.singleShot(1000, start)
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
self.overlay.hide()
|
||||
self.overlay.deleteLater()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def get_instances(self) -> List[GameInstance]:
|
||||
windows = Quartz.CGWindowListCopyWindowInfo(
|
||||
Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID
|
||||
@@ -58,6 +93,12 @@ class QuartzGameManager(GameManager):
|
||||
|
||||
return list(self._instances.values())
|
||||
|
||||
def get_active_instance(self) -> Union[GameInstance, None]:
|
||||
if not self._instances:
|
||||
return None
|
||||
|
||||
return self._instances.values()[0]
|
||||
|
||||
def get_instance_by_pid(self, pid: int) -> Optional[QuartzGameInstance]:
|
||||
for instance in self._instances.values():
|
||||
if instance.pid == pid:
|
||||
@@ -78,8 +119,9 @@ class QuartzGameManager(GameManager):
|
||||
instance = self.get_instance_by_pid(front_app.processIdentifier())
|
||||
else:
|
||||
instance = self._instances.get(nsevent.windowNumber())
|
||||
if not instance:
|
||||
return event
|
||||
|
||||
if not instance:
|
||||
return event
|
||||
|
||||
# Check for cmd1
|
||||
if nsevent.type() == Quartz.NSEventTypeKeyDown:
|
||||
@@ -94,25 +136,22 @@ class QuartzGameManager(GameManager):
|
||||
|
||||
return event
|
||||
|
||||
@Slot()
|
||||
def accessibility_popup(self):
|
||||
global has_prompted_accessibility
|
||||
if has_prompted_accessibility:
|
||||
return
|
||||
|
||||
# TODO: Check AXIsProcessTrusted?
|
||||
has_prompted_accessibility = True
|
||||
msgbox = QMessageBox(
|
||||
QMessageBox.Warning,
|
||||
"Permission required",
|
||||
"RuneKit needs Accessibility Access for global hotkey and game activity monitoring\n\nOpen System Preferences > Security > Privacy > Accessibility to allow this",
|
||||
"RuneKit needs Screen Recording permission\n\nOpen System Preferences > Security > Privacy > Screen Recording to allow this",
|
||||
QMessageBox.Open | QMessageBox.Ignore,
|
||||
)
|
||||
button = msgbox.exec()
|
||||
|
||||
if button == QMessageBox.Open:
|
||||
QDesktopServices.openUrl(
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Screen Recording"
|
||||
)
|
||||
|
||||
# FIXME: Closing this dialog will close the app, idk why
|
||||
# I think Qt recognize the dialog as the last app window (which is untrue)
|
||||
@@ -1,43 +1,180 @@
|
||||
from typing import TYPE_CHECKING
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, List, Union
|
||||
|
||||
from PySide2.QtCore import QRect
|
||||
import xcffib.composite
|
||||
import xcffib.xproto
|
||||
from PySide2.QtCore import QRect, Signal, Slot
|
||||
from PySide2.QtGui import QWindow, QGuiApplication
|
||||
from Xlib.xobject.drawable import Window
|
||||
from PySide2.QtWidgets import QGraphicsItem
|
||||
|
||||
from runekit.game.instance import GameInstance
|
||||
from runekit.game.qt import QtGrabMixin
|
||||
from runekit.game.psutil_mixins import PsUtilNetStat
|
||||
from runekit.game.qt import QtGrabMixin, QtEmbedMixin
|
||||
from .ximage import zpixmap_shm_to_image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import X11GameManager
|
||||
|
||||
|
||||
class X11GameInstance(QtGrabMixin, GameInstance):
|
||||
xwindow: Window
|
||||
class X11GameInstance(QtGrabMixin, QtEmbedMixin, PsUtilNetStat, GameInstance):
|
||||
wid: int
|
||||
refresh_rate = 100
|
||||
|
||||
manager: "X11GameManager"
|
||||
overlay: QGraphicsItem
|
||||
|
||||
def __init__(self, manager: "X11GameManager", window: Window, **kwargs):
|
||||
game_last_grab = 0.0
|
||||
game_last_image = None
|
||||
embedded_windows: List[QWindow]
|
||||
cached_position = None
|
||||
|
||||
input_signal = Signal(xcffib.Event)
|
||||
config_signal = Signal(xcffib.Event)
|
||||
destroy_signal = Signal(xcffib.Event)
|
||||
|
||||
def __init__(self, manager: "X11GameManager", wid: int, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.manager = manager
|
||||
self.xwindow = window
|
||||
self.qwindow = QWindow.fromWinId(self.xwindow.id)
|
||||
self.wid = wid
|
||||
self.pid = manager.get_property(wid, "_NET_WM_PID")
|
||||
self.qwindow = QWindow.fromWinId(wid)
|
||||
self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
|
||||
self.embedded_windows = []
|
||||
self._setup_x()
|
||||
self._setup_overlay()
|
||||
|
||||
self.input_signal.connect(self.on_input)
|
||||
self.config_signal.connect(self.on_config)
|
||||
|
||||
self._update_is_focused()
|
||||
|
||||
def get_position(self) -> QRect:
|
||||
geom = self.xwindow.get_geometry()
|
||||
def _setup_x(self):
|
||||
self.manager.xcomposite.RedirectWindow(
|
||||
self.wid, xcffib.composite.Redirect.Automatic
|
||||
)
|
||||
self.name_pixmap()
|
||||
self.manager.connection.core.ChangeWindowAttributesChecked(
|
||||
self.wid,
|
||||
xcffib.xproto.CW.EventMask,
|
||||
[
|
||||
xcffib.xproto.EventMask.KeyPress
|
||||
| xcffib.xproto.EventMask.StructureNotify
|
||||
],
|
||||
)
|
||||
|
||||
return QRect(geom.x, geom.y, geom.width, geom.height)
|
||||
def _setup_overlay(self):
|
||||
self.overlay, self._overlay_disconnect = self.manager.overlay.add_instance(self)
|
||||
|
||||
def __del__(self):
|
||||
self.manager.connection.core.FreePixmap(self.pixmap_id)
|
||||
self.manager.xcomposite.UnredirectWindow(
|
||||
self.wid, xcffib.composite.Redirect.Automatic
|
||||
)
|
||||
self._overlay_disconnect()
|
||||
|
||||
def name_pixmap(self):
|
||||
if hasattr(self, "pixmap_id"):
|
||||
self.manager.connection.core.FreePixmap(self.pixmap_id)
|
||||
|
||||
self.pixmap_id = self.manager.connection.generate_id()
|
||||
self.manager.xcomposite.NameWindowPixmap(
|
||||
self.wid, self.pixmap_id, is_checked=True
|
||||
)
|
||||
|
||||
def get_position(self) -> QRect:
|
||||
if self.cached_position:
|
||||
return self.cached_position
|
||||
|
||||
geom = self.manager.connection.core.GetGeometry(self.wid).reply()
|
||||
translated = self.manager.connection.core.TranslateCoordinates(
|
||||
self.wid, self.manager.screen.root, 0, 0
|
||||
).reply()
|
||||
|
||||
self.cached_position = QRect(
|
||||
translated.dst_x, translated.dst_y, geom.width, geom.height
|
||||
)
|
||||
return self.cached_position
|
||||
|
||||
def get_scaling(self) -> float:
|
||||
screen = QGuiApplication.screenAt(self.get_position().topLeft())
|
||||
pos = self.get_position().topLeft()
|
||||
if pos.x() < 0:
|
||||
pos.setX(0)
|
||||
if pos.y() < 0:
|
||||
pos.setY(0)
|
||||
|
||||
screen = QGuiApplication.screenAt(pos)
|
||||
return screen.devicePixelRatio()
|
||||
|
||||
def is_focused(self) -> bool:
|
||||
return self.is_focused
|
||||
return self._is_focused
|
||||
|
||||
def _update_is_focused(self):
|
||||
self.is_focused = self.manager.get_active_window() == self.xwindow.id
|
||||
self._is_focused = self.manager.get_active_window() == self.wid
|
||||
|
||||
def set_taskbar_progress(self, type_, progress):
|
||||
# Not supported
|
||||
# TODO: Implement this on Unity? I don't use Unity
|
||||
# TODO: If progress = 1 set urgency flag
|
||||
pass
|
||||
|
||||
def grab_game(self):
|
||||
if (time.monotonic() - self.game_last_grab) * 1000 < self.refresh_rate:
|
||||
return self.game_last_image
|
||||
|
||||
xid = shm = None
|
||||
try:
|
||||
geom = self.manager.connection.core.GetGeometry(self.pixmap_id).reply()
|
||||
xid, shm = self.manager.get_shm(geom.width * geom.height * 4)
|
||||
size = (
|
||||
self.manager.xshm.GetImage(
|
||||
self.pixmap_id,
|
||||
0,
|
||||
0,
|
||||
geom.width,
|
||||
geom.height,
|
||||
0xFFFFFF,
|
||||
xcffib.xproto.ImageFormat.ZPixmap,
|
||||
xid,
|
||||
0,
|
||||
)
|
||||
.reply()
|
||||
.size
|
||||
)
|
||||
|
||||
out = zpixmap_shm_to_image(shm, size, geom.width, geom.height)
|
||||
|
||||
self.game_last_image = out
|
||||
self.game_last_grab = time.monotonic()
|
||||
|
||||
return out
|
||||
finally:
|
||||
if shm:
|
||||
self.manager.free_shm((xid, shm))
|
||||
|
||||
def embed_window(self, window: QWindow):
|
||||
super().embed_window(window)
|
||||
self.embedded_windows.append(window)
|
||||
|
||||
def get_overlay_area(self) -> QGraphicsItem:
|
||||
return self.overlay
|
||||
|
||||
@Slot(xcffib.Event)
|
||||
def on_config(self, evt: xcffib.xproto.ConfigureNotifyEvent):
|
||||
self.name_pixmap()
|
||||
|
||||
translated = self.manager.connection.core.TranslateCoordinates(
|
||||
self.wid, self.manager.screen.root, 0, 0
|
||||
).reply()
|
||||
size = QRect(translated.dst_x, translated.dst_y, evt.width, evt.height)
|
||||
|
||||
self.cached_position = size
|
||||
self.positionChanged.emit(size)
|
||||
|
||||
@Slot(xcffib.Event)
|
||||
def on_input(
|
||||
self, evt: Union[xcffib.xproto.KeyPressEvent, xcffib.xproto.ButtonPressEvent]
|
||||
):
|
||||
if evt.detail == 10 and evt.state & xcffib.xproto.KeyButMask.Mod1:
|
||||
self.alt1_pressed.emit()
|
||||
else:
|
||||
self.game_activity.emit()
|
||||
@@ -1,157 +1,294 @@
|
||||
import logging
|
||||
from typing import List, Dict
|
||||
import os
|
||||
import struct
|
||||
from typing import List, Dict, Tuple, Union
|
||||
|
||||
from PySide2.QtCore import QThread, Slot, Signal, QObject
|
||||
from Xlib import X
|
||||
from Xlib.display import Display
|
||||
from Xlib.ext import xinput, ge
|
||||
from Xlib.protocol import event
|
||||
from Xlib.xobject.drawable import Window
|
||||
import Xlib.threaded
|
||||
import sysv_ipc
|
||||
import xcffib
|
||||
import xcffib.composite
|
||||
import xcffib.shm
|
||||
import xcffib.xinput
|
||||
import xcffib.xproto
|
||||
from PySide2.QtCore import QThread, Slot, QObject, Signal
|
||||
|
||||
from runekit.game import GameManager
|
||||
from .instance import GameInstance, X11GameInstance
|
||||
from ..overlay import DesktopWideOverlay
|
||||
|
||||
MAX_SHM = 10
|
||||
NET_ACTIVE_WINDOW = "_NET_ACTIVE_WINDOW"
|
||||
WM_APP_NAME = os.getenv("RK_WM_APP_NAME", "RuneScape")
|
||||
|
||||
|
||||
class X11GameManager(GameManager):
|
||||
connection: xcffib.Connection
|
||||
|
||||
_instances: Dict[int, X11GameInstance]
|
||||
_atom: Dict[bytes, int]
|
||||
_shm: List[Tuple[int, sysv_ipc.SharedMemory]]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(*kwargs)
|
||||
self._instances = {}
|
||||
self._atom = {}
|
||||
self._shm = []
|
||||
|
||||
self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
|
||||
self.connection = xcffib.Connection()
|
||||
self.screen = self.connection.get_screen_pointers()[self.connection.pref_screen]
|
||||
self.xcomposite = self.connection(xcffib.composite.key)
|
||||
self.xshm = self.connection(xcffib.shm.key)
|
||||
self.xinput = self.connection(xcffib.xinput.key)
|
||||
self._setup_composite()
|
||||
self._setup_overlay()
|
||||
|
||||
self.event_thread = QThread(self)
|
||||
|
||||
self.event_worker = X11EventWorker(self)
|
||||
self.event_worker.moveToThread(self.event_thread)
|
||||
self.event_thread.started.connect(self.event_worker.run)
|
||||
self.event_thread.finished.connect(self.event_worker.deleteLater)
|
||||
self.event_worker.create_signal.connect(self.on_game_opened)
|
||||
self.event_worker.destroy_signal.connect(self.on_game_closed)
|
||||
|
||||
self.event_thread.start()
|
||||
|
||||
self.get_instances()
|
||||
|
||||
def stop(self):
|
||||
self.event_thread.requestInterruption()
|
||||
self.event_thread.quit()
|
||||
self.event_thread.wait()
|
||||
|
||||
def get_instances(self) -> List[GameInstance]:
|
||||
def visit(wid: int):
|
||||
if self.is_game(wid):
|
||||
if wid not in self._instances:
|
||||
self.logger.info("Found game instance %d", wid)
|
||||
instance = X11GameInstance(self, wid, parent=self)
|
||||
self._instances[wid] = instance
|
||||
|
||||
try:
|
||||
query = self.connection.core.QueryTree(wid).reply()
|
||||
for child in query.children:
|
||||
visit(child)
|
||||
except xcffib.xproto.WindowError:
|
||||
return
|
||||
|
||||
visit(self.screen.root)
|
||||
|
||||
return list(self._instances.values())
|
||||
|
||||
def get_active_instance(self) -> Union[GameInstance, None]:
|
||||
for instance in self._instances.values():
|
||||
if instance.is_focused():
|
||||
return instance
|
||||
|
||||
return None
|
||||
|
||||
def is_game(self, wid: int) -> bool:
|
||||
geom_req = self.connection.core.GetGeometry(wid)
|
||||
try:
|
||||
wm_class = self.get_property(wid, xcffib.xproto.Atom.WM_CLASS)
|
||||
except xcffib.xproto.WindowError:
|
||||
geom_req.discard_reply()
|
||||
return False
|
||||
|
||||
geom = geom_req.reply()
|
||||
if geom.width == 32 and geom.height == 32:
|
||||
# OpenGL test window
|
||||
return False
|
||||
|
||||
if not wm_class:
|
||||
return False
|
||||
|
||||
instance_name, app_name = wm_class.split("\00")
|
||||
return app_name == WM_APP_NAME
|
||||
|
||||
def get_active_window(self) -> int:
|
||||
return self.get_property(self.screen.root, "_NET_ACTIVE_WINDOW")
|
||||
|
||||
def _setup_overlay(self):
|
||||
self.overlay = DesktopWideOverlay()
|
||||
self.overlay.show()
|
||||
self.overlay.check_compatibility()
|
||||
|
||||
def _setup_composite(self):
|
||||
self.xcomposite.QueryVersion(0, 4, is_checked=True)
|
||||
|
||||
def get_property(
|
||||
self,
|
||||
wid: int,
|
||||
name: str,
|
||||
type_=xcffib.xproto.GetPropertyType.Any,
|
||||
index=0,
|
||||
max_values=1000,
|
||||
):
|
||||
reply = self.connection.core.GetProperty(
|
||||
False,
|
||||
wid,
|
||||
self.get_atom(name),
|
||||
type_,
|
||||
index,
|
||||
max_values,
|
||||
).reply()
|
||||
|
||||
if reply.type == xcffib.xproto.Atom.STRING:
|
||||
return reply.value.to_string()[:-1]
|
||||
elif reply.type in (xcffib.xproto.Atom.WINDOW, xcffib.xproto.Atom.CARDINAL):
|
||||
return struct.unpack("=I", reply.value.buf()[:4])[0]
|
||||
|
||||
return reply.value
|
||||
|
||||
def get_atom(self, atom: str) -> int:
|
||||
if isinstance(atom, int):
|
||||
return atom
|
||||
|
||||
if hasattr(xcffib.xproto.Atom, atom):
|
||||
return getattr(xcffib.xproto.Atom, atom)
|
||||
|
||||
atom = atom.encode("ascii")
|
||||
|
||||
if atom in self._atom:
|
||||
return self._atom[atom]
|
||||
|
||||
out = self.connection.core.InternAtom(False, len(atom), atom).reply().atom
|
||||
self._atom[atom] = out
|
||||
|
||||
return out
|
||||
|
||||
def get_shm(self, size: int) -> Tuple[int, sysv_ipc.SharedMemory]:
|
||||
for item in self._shm:
|
||||
if item[1].size >= size:
|
||||
self._shm.remove(item)
|
||||
return item
|
||||
|
||||
shm = sysv_ipc.SharedMemory(None, flags=sysv_ipc.IPC_CREX, size=size)
|
||||
xid = self.connection.generate_id()
|
||||
self.xshm.Attach(xid, shm.id, False, is_checked=True)
|
||||
return xid, shm
|
||||
|
||||
def free_shm(self, shm: Tuple[int, sysv_ipc.SharedMemory]):
|
||||
self._shm.append(shm)
|
||||
self.gc_shm()
|
||||
|
||||
def gc_shm(self):
|
||||
while len(self._shm) > MAX_SHM:
|
||||
xid, shm = self._shm.pop(0)
|
||||
self.xshm.Detach(xid)
|
||||
shm.detach()
|
||||
shm.remove()
|
||||
|
||||
@Slot(xcffib.Event)
|
||||
def on_game_opened(self, evt: xcffib.xproto.CreateNotifyEvent):
|
||||
self.logger.info("New game window opened %d", evt.window)
|
||||
self._instances[evt.window] = X11GameInstance(self, evt.window, parent=self)
|
||||
|
||||
@Slot(int)
|
||||
def on_game_closed(self, wid: int):
|
||||
if wid not in self._instances:
|
||||
return
|
||||
|
||||
self.logger.info("Game window %d closed", wid)
|
||||
instance = self._instances[wid]
|
||||
del self._instances[wid]
|
||||
self.instance_removed.emit(instance)
|
||||
self.instance_changed.emit()
|
||||
|
||||
|
||||
class X11EventWorker(QObject):
|
||||
on_active_window_changed = Signal(int)
|
||||
on_alt1 = Signal(int)
|
||||
create_signal = Signal(xcffib.Event)
|
||||
destroy_signal = Signal(int)
|
||||
|
||||
def __init__(self, manager: "X11GameManager", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.manager = manager
|
||||
self.display = manager.display
|
||||
self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
|
||||
|
||||
self.handlers = {
|
||||
ge.GenericEventCode: self.dispatch_ge,
|
||||
X.PropertyNotify: self.on_property_change,
|
||||
xcffib.xproto.PropertyNotifyEvent: self.on_property_change,
|
||||
xcffib.xproto.ConfigureNotifyEvent: self.on_configure_event,
|
||||
xcffib.xproto.KeyPressEvent: self.on_input_event,
|
||||
xcffib.xproto.ButtonPressEvent: self.on_input_event,
|
||||
xcffib.xproto.CreateNotifyEvent: self.on_create,
|
||||
xcffib.xproto.DestroyNotifyEvent: self.on_destroy,
|
||||
}
|
||||
self.ge_handlers = {xinput.KeyPress: self.on_key_press}
|
||||
self.active_win_id = self.manager.get_active_window()
|
||||
|
||||
self._NET_ACTIVE_WINDOW = self.display.get_atom("_NET_ACTIVE_WINDOW")
|
||||
|
||||
@Slot()
|
||||
def run(self):
|
||||
root = self.display.screen().root
|
||||
root.change_attributes(event_mask=X.PropertyChangeMask)
|
||||
self.manager.connection.core.ChangeWindowAttributesChecked(
|
||||
self.manager.screen.root,
|
||||
xcffib.xproto.CW.EventMask,
|
||||
[
|
||||
xcffib.xproto.EventMask.PropertyChange
|
||||
| xcffib.xproto.EventMask.SubstructureNotify,
|
||||
],
|
||||
).check()
|
||||
current_thread = QThread.currentThread()
|
||||
|
||||
while True:
|
||||
evt = self.display.next_event()
|
||||
if evt.send_event != 0:
|
||||
if current_thread.isInterruptionRequested():
|
||||
return
|
||||
|
||||
evt = self.manager.connection.poll_for_event()
|
||||
if evt is None:
|
||||
QThread.msleep(10)
|
||||
continue
|
||||
|
||||
try:
|
||||
handler = self.handlers.get(evt.type)
|
||||
if handler:
|
||||
handler(evt)
|
||||
except:
|
||||
self.logger.error("Error handling event %s", repr(evt), exc_info=True)
|
||||
continue
|
||||
for wanted_type, handler in self.handlers.items():
|
||||
if isinstance(evt, wanted_type):
|
||||
try:
|
||||
handler(evt)
|
||||
except:
|
||||
self.logger.error(
|
||||
"Error handling event %s", repr(evt), exc_info=True
|
||||
)
|
||||
|
||||
def dispatch_ge(self, evt: ge.GenericEvent):
|
||||
handler = self.ge_handlers.get(evt.evtype)
|
||||
if handler:
|
||||
handler(evt)
|
||||
|
||||
def on_key_press(self, evt):
|
||||
window = evt.data.event
|
||||
# alt1
|
||||
if evt.data.mods.effective_mods & X.Mod1Mask and evt.data.detail == 10:
|
||||
self.on_alt1.emit(window.id)
|
||||
self.logger.debug("alt1 pressed %s", repr(evt))
|
||||
|
||||
window.xinput_ungrab_keycode(evt.data.deviceid, evt.data.detail, (X.Mod1Mask,))
|
||||
self.manager._setup_grab(window)
|
||||
|
||||
def on_property_change(self, evt: event.PropertyNotify):
|
||||
if evt.atom == self._NET_ACTIVE_WINDOW:
|
||||
def on_property_change(self, evt: xcffib.xproto.PropertyNotifyEvent):
|
||||
if (
|
||||
evt.state == xcffib.xproto.Property.NewValue
|
||||
and evt.atom == self.manager.get_atom(NET_ACTIVE_WINDOW)
|
||||
and evt.window == self.manager.screen.root
|
||||
):
|
||||
active_win_id = self.manager.get_active_window()
|
||||
|
||||
if self.active_win_id == active_win_id:
|
||||
return
|
||||
|
||||
self.active_win_id = active_win_id
|
||||
self.logger.debug(
|
||||
"Active window changed to %d - %s", active_win_id, repr(evt)
|
||||
)
|
||||
self.on_active_window_changed.emit(active_win_id)
|
||||
|
||||
for id_, instance in self.manager._instances.items():
|
||||
active = active_win_id == id_
|
||||
|
||||
class X11GameManager(GameManager):
|
||||
display: Display
|
||||
if active != instance._is_focused:
|
||||
instance._is_focused = active
|
||||
instance.focusChanged.emit(active)
|
||||
|
||||
_instance: Dict[int, X11GameInstance]
|
||||
def on_input_event(
|
||||
self, evt: Union[xcffib.xproto.KeyPressEvent, xcffib.xproto.ButtonPressEvent]
|
||||
):
|
||||
try:
|
||||
self.manager._instances[evt.event].input_signal.emit(evt)
|
||||
except KeyError:
|
||||
self.logger.debug("Got input event for %d but is not registered", evt.event)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(*kwargs)
|
||||
self.display = Display()
|
||||
self._NET_ACTIVE_WINDOW = self.display.get_atom("_NET_ACTIVE_WINDOW")
|
||||
self._instance = {}
|
||||
def on_configure_event(self, evt: xcffib.xproto.ConfigureNotifyEvent):
|
||||
try:
|
||||
self.manager._instances[evt.window].config_signal.emit(evt)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
self.event_thread = QThread()
|
||||
def on_create(self, evt: xcffib.xproto.CreateNotifyEvent):
|
||||
if evt.window in self.manager._instances:
|
||||
return
|
||||
|
||||
self.event_worker = X11EventWorker(self)
|
||||
self.event_worker.moveToThread(self.event_thread)
|
||||
self.event_thread.started.connect(self.event_worker.run)
|
||||
self.event_worker.on_active_window_changed.connect(
|
||||
self.on_active_window_changed
|
||||
)
|
||||
self.event_worker.on_alt1.connect(self.on_alt1)
|
||||
# At this point we aren't sure if it's actually the game...
|
||||
if not self.manager.is_game(evt.window):
|
||||
return
|
||||
|
||||
self.event_thread.start()
|
||||
self.create_signal.emit(evt)
|
||||
|
||||
def get_instances(self) -> List[GameInstance]:
|
||||
def visit(window):
|
||||
try:
|
||||
wm_class = window.get_wm_class()
|
||||
except:
|
||||
return
|
||||
|
||||
if wm_class and wm_class[0] == "RuneScape":
|
||||
if window.id not in self._instance:
|
||||
instance = X11GameInstance(self, window, parent=self)
|
||||
self._setup_grab(window)
|
||||
self._instance[window.id] = instance
|
||||
|
||||
for child in window.query_tree().children:
|
||||
visit(child)
|
||||
|
||||
visit(self.display.screen().root)
|
||||
|
||||
return list(self._instance.values())
|
||||
|
||||
def _setup_grab(self, window: Window):
|
||||
# alt1
|
||||
window.xinput_grab_keycode(
|
||||
xinput.AllDevices,
|
||||
X.CurrentTime,
|
||||
10,
|
||||
xinput.GrabModeAsync,
|
||||
xinput.GrabModeAsync,
|
||||
True,
|
||||
xinput.KeyPressMask,
|
||||
(X.Mod1Mask,),
|
||||
)
|
||||
|
||||
def get_active_window(self) -> int:
|
||||
resp = self.display.screen().root.get_full_property(
|
||||
self._NET_ACTIVE_WINDOW, X.AnyPropertyType
|
||||
)
|
||||
return resp.value[0]
|
||||
|
||||
@Slot(int)
|
||||
def on_active_window_changed(self, winid):
|
||||
for id_, instance in self._instance.items():
|
||||
active = winid == id_
|
||||
|
||||
if active != instance.is_focused:
|
||||
instance.focusChanged.emit(active)
|
||||
|
||||
instance.is_focused = active
|
||||
|
||||
@Slot(int)
|
||||
def on_alt1(self, winid):
|
||||
if winid in self._instance:
|
||||
self._instance[winid].alt1_pressed.emit()
|
||||
def on_destroy(self, evt: xcffib.xproto.DestroyNotifyEvent):
|
||||
if evt.window in self.manager._instances:
|
||||
self.destroy_signal.emit(evt.window)
|
||||
@@ -0,0 +1,13 @@
|
||||
import sysv_ipc
|
||||
import numpy as np
|
||||
from PIL import ImageOps, Image
|
||||
|
||||
|
||||
def zpixmap_shm_to_image(
|
||||
shm: sysv_ipc.SharedMemory, size: int, width: int, height: int
|
||||
) -> np.ndarray:
|
||||
out = np.frombuffer(shm.read(size), "<B").copy()
|
||||
out.shape = (height, width, 4)
|
||||
out[:, :, 3] = 0xFF # Convert BGRX to BGRA
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,194 @@
|
||||
import typing
|
||||
import json
|
||||
from typing import Optional, TypedDict, Union, List
|
||||
|
||||
from PySide2.QtCore import QAbstractItemModel, Qt, QModelIndex, QMimeData
|
||||
from PySide2.QtGui import QIcon
|
||||
|
||||
from runekit.alt1.schema import AppManifest
|
||||
from runekit.app import AppStore
|
||||
|
||||
MIMETYPE = "application/x-qabstractitemmodeldatalist"
|
||||
|
||||
|
||||
class _InternalData(TypedDict):
|
||||
id: str
|
||||
data: Optional[AppManifest]
|
||||
index: QModelIndex
|
||||
parent: Union[QModelIndex, None]
|
||||
children: List["_InternalData"]
|
||||
|
||||
|
||||
class AppStoreModel(QAbstractItemModel):
|
||||
h_columns = ("Name", "URL", "Description")
|
||||
model: List[_InternalData]
|
||||
|
||||
def __init__(self, parent, store: AppStore):
|
||||
super().__init__(parent)
|
||||
self.store = store
|
||||
self.store.app_change.connect(self.on_app_change)
|
||||
self.model = self.build_model()
|
||||
|
||||
def on_app_change(self):
|
||||
self.beginResetModel()
|
||||
self.model = self.build_model()
|
||||
self.endResetModel()
|
||||
|
||||
def build_model(self, root="", parent=None) -> List[_InternalData]:
|
||||
apps = self.store.list_app(root)
|
||||
out = []
|
||||
|
||||
for app_id, meta in apps:
|
||||
if not meta:
|
||||
|
||||
data = {
|
||||
"id": app_id,
|
||||
"parent": parent,
|
||||
}
|
||||
index = data["index"] = self.createIndex(len(out), 0, data)
|
||||
data["children"] = self.build_model(app_id, index)
|
||||
out.append(data)
|
||||
continue
|
||||
|
||||
data = {
|
||||
"id": app_id,
|
||||
"data": meta,
|
||||
"parent": parent,
|
||||
}
|
||||
data["index"] = self.createIndex(len(out), 0, data)
|
||||
out.append(data)
|
||||
|
||||
return out
|
||||
|
||||
def columnCount(self, parent: QModelIndex) -> int:
|
||||
return len(self.h_columns)
|
||||
|
||||
def rowCount(self, parent: QModelIndex) -> int:
|
||||
if parent.isValid():
|
||||
data = typing.cast(_InternalData, parent.internalPointer())
|
||||
return len(data["children"])
|
||||
|
||||
return len(self.model)
|
||||
|
||||
def data(self, index: QModelIndex, role: int):
|
||||
left_col = index.siblingAtColumn(0)
|
||||
col = index.column()
|
||||
app = typing.cast(_InternalData, left_col.internalPointer())
|
||||
|
||||
if "data" not in app:
|
||||
# Folder
|
||||
if role == Qt.DisplayRole:
|
||||
if col == 0:
|
||||
return app["id"]
|
||||
elif role == Qt.DecorationRole:
|
||||
if col == 0:
|
||||
return QIcon.fromTheme("folder")
|
||||
|
||||
return None
|
||||
|
||||
if role in (Qt.DisplayRole, Qt.ToolTipRole):
|
||||
if col == 0:
|
||||
return app["data"]["appName"]
|
||||
elif col == 1:
|
||||
return app["data"]["appUrl"]
|
||||
elif col == 2:
|
||||
return app["data"]["description"]
|
||||
elif role == Qt.DecorationRole and col == 0:
|
||||
return self.store.icon(app["id"])
|
||||
else:
|
||||
return None
|
||||
|
||||
def hasChildren(self, index: QModelIndex) -> bool:
|
||||
if not index.isValid():
|
||||
return True
|
||||
if index.column() != 0 or not index.internalPointer():
|
||||
return False
|
||||
|
||||
data = typing.cast(_InternalData, index.internalPointer())
|
||||
return "data" not in data
|
||||
|
||||
def index(self, row: int, column: int, parent: QModelIndex) -> QModelIndex:
|
||||
if column != 0:
|
||||
leftmost = self.index(row, 0, parent)
|
||||
return self.createIndex(row, column, leftmost.internalPointer())
|
||||
|
||||
if parent.isValid():
|
||||
data = typing.cast(_InternalData, parent.internalPointer())
|
||||
return data["children"][row]["index"]
|
||||
|
||||
if row >= len(self.model):
|
||||
return QModelIndex()
|
||||
|
||||
return self.model[row]["index"]
|
||||
|
||||
def parent(self, index: QModelIndex) -> QModelIndex:
|
||||
data = typing.cast(_InternalData, index.internalPointer())
|
||||
return data["parent"] or QModelIndex()
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
|
||||
out = Qt.ItemIsSelectable | Qt.ItemIsEnabled
|
||||
|
||||
if not index.isValid():
|
||||
return out | Qt.ItemIsDropEnabled
|
||||
|
||||
data = typing.cast(_InternalData, index.internalPointer())
|
||||
if "data" in data:
|
||||
out |= Qt.ItemIsDragEnabled
|
||||
else:
|
||||
# Can't drag folder but can drop
|
||||
out |= Qt.ItemIsDropEnabled
|
||||
if "children" not in data:
|
||||
out |= Qt.ItemNeverHasChildren
|
||||
|
||||
return out
|
||||
|
||||
def headerData(self, section: int, orientation: Qt.Orientation, role: int):
|
||||
if role != Qt.DisplayRole:
|
||||
return None
|
||||
if orientation != Qt.Orientation.Horizontal:
|
||||
return None
|
||||
|
||||
return self.h_columns[section]
|
||||
|
||||
def supportedDropActions(self) -> Qt.DropActions:
|
||||
return Qt.MoveAction
|
||||
|
||||
def mimeData(self, indexes: List[QModelIndex]) -> QMimeData:
|
||||
indexes = [item for item in indexes if item.column() == 0]
|
||||
if not indexes:
|
||||
return None
|
||||
|
||||
data = typing.cast(_InternalData, indexes[0].internalPointer())
|
||||
out = QMimeData()
|
||||
out_data = {
|
||||
"id": data["id"],
|
||||
"parent": "",
|
||||
"type": "dir" if "data" not in data else "app",
|
||||
}
|
||||
if data["parent"]:
|
||||
out_data["parent"] = data["parent"].internalPointer()["id"]
|
||||
|
||||
out.setData(MIMETYPE, json.dumps(out_data).encode("ascii"))
|
||||
return out
|
||||
|
||||
def dropMimeData(
|
||||
self,
|
||||
data: QMimeData,
|
||||
action: Qt.DropAction,
|
||||
row: int,
|
||||
column: int,
|
||||
parent: QModelIndex,
|
||||
) -> bool:
|
||||
data = json.loads(data.data(MIMETYPE).data())
|
||||
if parent.isValid():
|
||||
new_folder = parent.internalPointer()["id"]
|
||||
else:
|
||||
new_folder = ""
|
||||
|
||||
if data["parent"] == new_folder:
|
||||
return False
|
||||
|
||||
self.store.add_app_to_folder(data["id"], new_folder)
|
||||
self.store.delete_app_from_folder(data["id"], data["parent"])
|
||||
|
||||
return True
|
||||
@@ -1,29 +1,116 @@
|
||||
from typing import TYPE_CHECKING
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from PySide2.QtWidgets import QWidget
|
||||
from PySide2.QtCore import Slot, QCoreApplication
|
||||
from PySide2.QtWidgets import QMessageBox
|
||||
|
||||
from runekit.alt1.utils import fetch_manifest
|
||||
from runekit.app import App
|
||||
from runekit.alt1.schema import AppManifest
|
||||
from runekit.alt1.utils import fetch_bom_json
|
||||
from runekit.app import App, AppStore
|
||||
from runekit.app.store import app_id
|
||||
from runekit.host.settings import SettingsDialog
|
||||
from runekit.ui import AutoNotifier
|
||||
from runekit.ui.tray import TrayIcon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from runekit.game import GameInstance
|
||||
from runekit.game import GameInstance, GameManager
|
||||
|
||||
|
||||
class Host(QWidget):
|
||||
class Host:
|
||||
"""Host is the root of our application"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.notifier = AutoNotifier()
|
||||
open_app: List[App]
|
||||
|
||||
def start_app(self, manifest_url, instance: "GameInstance") -> App:
|
||||
manifest = fetch_manifest(manifest_url)
|
||||
def __init__(self, manager: "GameManager"):
|
||||
super().__init__()
|
||||
self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
|
||||
self.manager = manager
|
||||
self.open_app = []
|
||||
self.notifier = AutoNotifier()
|
||||
self.app_store = AppStore()
|
||||
self.tray_icon = TrayIcon(self)
|
||||
self.setting_dialog = SettingsDialog(self)
|
||||
self.tray_icon.show()
|
||||
|
||||
QCoreApplication.instance().aboutToQuit.connect(self.on_before_quit)
|
||||
self.app_store.app_change.connect(self.tray_icon.update_menu)
|
||||
self.tray_icon.on_settings.connect(self.open_settings)
|
||||
self.manager.instance_removed.connect(self.on_game_quit)
|
||||
|
||||
def on_before_quit(self):
|
||||
for app in self.open_app:
|
||||
app.close()
|
||||
|
||||
def __del__(self):
|
||||
self.open_app = []
|
||||
|
||||
def launch_app_from_url(self, manifest_url) -> App:
|
||||
manifest = fetch_bom_json(manifest_url)
|
||||
manifest["configUrl"] = manifest_url
|
||||
return self.launch_app(app_id(manifest_url), manifest)
|
||||
|
||||
@Slot(str)
|
||||
def launch_app_id(self, appid):
|
||||
try:
|
||||
manifest = self.app_store[appid]
|
||||
except KeyError:
|
||||
QMessageBox.critical(
|
||||
None,
|
||||
"App missing",
|
||||
"Cannot find application by that ID",
|
||||
)
|
||||
return
|
||||
|
||||
self.launch_app(appid, manifest)
|
||||
|
||||
def launch_app(self, appid: str, manifest: AppManifest):
|
||||
instance = self.manager.get_active_instance()
|
||||
|
||||
if not instance:
|
||||
instances = self.manager.get_instances()
|
||||
if not instances:
|
||||
QMessageBox.critical(
|
||||
None,
|
||||
"No game instances found",
|
||||
"Cannot find open RuneScape window. Please launch the game before any apps",
|
||||
)
|
||||
return
|
||||
|
||||
instance = instances[0]
|
||||
|
||||
app = App(
|
||||
host=self,
|
||||
app_id=appid,
|
||||
manifest=manifest,
|
||||
game_instance=instance,
|
||||
source_url=manifest_url,
|
||||
source_url=manifest["configUrl"],
|
||||
)
|
||||
app.get_window(parent=self).show()
|
||||
|
||||
def on_destroy():
|
||||
try:
|
||||
self.open_app.remove(app)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
window = app.get_window()
|
||||
window.show()
|
||||
window.destroyed.connect(on_destroy)
|
||||
self.open_app.append(app)
|
||||
return app
|
||||
|
||||
@Slot()
|
||||
def open_settings(self):
|
||||
self.setting_dialog.show()
|
||||
self.setting_dialog.raise_()
|
||||
|
||||
@Slot()
|
||||
def on_game_quit(self, instance: "GameInstance"):
|
||||
self.logger.info("Game instance is closing")
|
||||
for app in self.open_app[:]:
|
||||
if app.game_instance == instance:
|
||||
self.logger.debug(
|
||||
"Closing app window %s because instance is closing",
|
||||
app.manifest["appName"],
|
||||
)
|
||||
app.close()
|
||||
self.open_app.remove(app)
|
||||
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import QSize, Qt, Slot, QTimer, QSettings
|
||||
from PySide2.QtGui import QIcon
|
||||
from PySide2.QtWidgets import (
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QMainWindow,
|
||||
QWidget,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QComboBox,
|
||||
QCheckBox,
|
||||
QTableWidget,
|
||||
QHBoxLayout,
|
||||
QTreeView,
|
||||
QSpinBox,
|
||||
QPushButton,
|
||||
QAbstractItemView,
|
||||
QInputDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
|
||||
from .appstore_model import AppStoreModel
|
||||
from ..ui import TooltipNotifier, TrayIconNotifier, AutoNotifier
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .host import Host
|
||||
|
||||
|
||||
class SettingsDialog(QMainWindow):
|
||||
def __init__(self, host: "Host", **kwargs):
|
||||
super().__init__(flags=Qt.Dialog, **kwargs)
|
||||
self.host = host
|
||||
self._layout()
|
||||
self.setWindowTitle("RuneKit Settings")
|
||||
|
||||
def _layout(self):
|
||||
tab = QTabWidget(self)
|
||||
tab.addTab(ApplicationPage(self.host, parent=self), "Applications")
|
||||
tab.addTab(InterfacePage(self.host, parent=self), "Interface")
|
||||
|
||||
self.setCentralWidget(tab)
|
||||
self.setContentsMargins(11, 11, 11, 11)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(800, 500)
|
||||
|
||||
|
||||
class ApplicationPage(QWidget):
|
||||
host: "Host"
|
||||
|
||||
def __init__(self, host: "Host", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.host = host
|
||||
self.model = AppStoreModel(self, self.host.app_store)
|
||||
self._layout()
|
||||
|
||||
def _layout(self):
|
||||
layout = QHBoxLayout(self)
|
||||
self.setLayout(layout)
|
||||
|
||||
self.tree = QTreeView(self)
|
||||
self.tree.setModel(self.model)
|
||||
self.tree.setUniformRowHeights(True)
|
||||
self.tree.setColumnWidth(0, 200)
|
||||
self.tree.setDragEnabled(True)
|
||||
self.tree.setDragDropMode(QAbstractItemView.InternalMove)
|
||||
self.tree.viewport().setAcceptDrops(True)
|
||||
layout.addWidget(self.tree, 1)
|
||||
|
||||
buttons = QVBoxLayout()
|
||||
buttons.setAlignment(Qt.AlignTop)
|
||||
|
||||
add_button = QPushButton(QIcon.fromTheme("list-add"), "", self)
|
||||
add_button.setToolTip("Add application")
|
||||
add_button.clicked.connect(self.on_add)
|
||||
buttons.addWidget(add_button)
|
||||
|
||||
mkdir_button = QPushButton(QIcon.fromTheme("folder-new"), "", self)
|
||||
mkdir_button.setToolTip("Make directory")
|
||||
mkdir_button.clicked.connect(self.on_mkdir)
|
||||
buttons.addWidget(mkdir_button)
|
||||
|
||||
delete_button = QPushButton(QIcon.fromTheme("list-remove"), "", self)
|
||||
delete_button.setToolTip("Remove selected item")
|
||||
delete_button.clicked.connect(self.on_delete)
|
||||
buttons.addWidget(delete_button)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def on_add(self):
|
||||
dialog = QInputDialog(self)
|
||||
dialog.setLabelText("Enter appconfig.json URL")
|
||||
|
||||
if dialog.exec_() == QInputDialog.Rejected:
|
||||
return
|
||||
|
||||
app_url = dialog.textValue().strip()
|
||||
self.host.app_store.add_app_ui([app_url])
|
||||
|
||||
def on_mkdir(self):
|
||||
dialog = QInputDialog(self)
|
||||
dialog.setLabelText("Directory name")
|
||||
|
||||
if dialog.exec_() == QInputDialog.Rejected:
|
||||
return
|
||||
|
||||
dirname = dialog.textValue().strip()
|
||||
|
||||
if not dirname or "/" in dirname:
|
||||
QMessageBox.critical(
|
||||
self, "Invalid input", "This directory name cannot be used"
|
||||
)
|
||||
return
|
||||
|
||||
self.host.app_store.mkdir(dirname)
|
||||
|
||||
def on_delete(self):
|
||||
data = self.model.mimeData(self.tree.selectedIndexes())
|
||||
if not data:
|
||||
return
|
||||
|
||||
data = json.loads(data.data("application/x-qabstractitemmodeldatalist").data())
|
||||
if data["type"] == "dir":
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"Remove folder",
|
||||
f"Remove {data['id']}?\n\nAll applications will be moved to the top level",
|
||||
)
|
||||
if confirm == QMessageBox.StandardButton.No:
|
||||
return
|
||||
|
||||
self.host.app_store.rmdir(data["id"])
|
||||
else:
|
||||
app = self.host.app_store[data["id"]]
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"Remove application",
|
||||
f"Remove {app['appName']}?",
|
||||
)
|
||||
if confirm == QMessageBox.StandardButton.No:
|
||||
return
|
||||
|
||||
self.host.app_store.remove_app(data["id"])
|
||||
# FIXME: Clear settings???
|
||||
|
||||
|
||||
class InterfacePage(QWidget):
|
||||
host: "Host"
|
||||
|
||||
def __init__(self, host: "Host", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.host = host
|
||||
self.settings = QSettings(self)
|
||||
self._layout()
|
||||
|
||||
def _layout(self):
|
||||
layout = QFormLayout(self)
|
||||
self.setLayout(layout)
|
||||
|
||||
layout.addRow(
|
||||
None,
|
||||
QLabel("RuneKit needs to be restarted for changes to take effect", self),
|
||||
)
|
||||
|
||||
tooltip_label = QLabel("Display tooltips as", self)
|
||||
tooltip_field = QComboBox(self)
|
||||
|
||||
selected = int(
|
||||
self.settings.value("settings/tooltip", AutoNotifier.METHOD_NOTIFICATION)
|
||||
)
|
||||
selected_idx = 0
|
||||
for idx, method in enumerate(AutoNotifier.availableMethods().items()):
|
||||
tooltip_field.insertItem(idx, method[1])
|
||||
if method[0] == selected:
|
||||
selected_idx = idx
|
||||
|
||||
tooltip_field.setCurrentIndex(selected_idx)
|
||||
|
||||
tooltip_field.currentIndexChanged.connect(self.preview_tooltip)
|
||||
tooltip_field.currentIndexChanged.connect(self.on_tooltip_change)
|
||||
layout.addRow(tooltip_label, tooltip_field)
|
||||
|
||||
border_field = QCheckBox("Styled window border", self)
|
||||
border_field.setDisabled(sys.platform == "darwin")
|
||||
border_field.setChecked(
|
||||
(self.settings.value("settings/styledBorder", "true") == "true")
|
||||
and sys.platform != "darwin"
|
||||
)
|
||||
border_field.stateChanged.connect(self.on_change_styled_border)
|
||||
layout.addRow(None, border_field)
|
||||
|
||||
@Slot(int)
|
||||
def preview_tooltip(self, index: int):
|
||||
type_ = list(AutoNotifier.availableMethods().items())[index][0]
|
||||
if type_ == AutoNotifier.METHOD_NOTIFICATION:
|
||||
notifier = TrayIconNotifier()
|
||||
elif type_ == AutoNotifier.METHOD_TOOLTIP:
|
||||
notifier = TooltipNotifier()
|
||||
else:
|
||||
return
|
||||
|
||||
if hasattr(self, "_last_notifier"):
|
||||
self._last_notifier.notify("")
|
||||
|
||||
self._last_notifier = notifier
|
||||
notifier.notify("This is an example notification")
|
||||
QTimer.singleShot(3000, lambda: notifier.notify(""))
|
||||
|
||||
@Slot(int)
|
||||
def on_tooltip_change(self, index: int):
|
||||
idx = list(AutoNotifier.availableMethods().items())[index]
|
||||
self.settings.setValue("settings/tooltip", idx[0])
|
||||
|
||||
@Slot(int)
|
||||
def on_change_styled_border(self, state: int):
|
||||
checked = state == Qt.Checked
|
||||
self.settings.setValue("settings/styledBorder", checked)
|
||||
@@ -1,27 +0,0 @@
|
||||
import logging
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
from .format import image_to_bgra
|
||||
except ImportError:
|
||||
logging.warning("Cannot import native image code, using pure python")
|
||||
|
||||
image_8bpp = struct.Struct("BBBB")
|
||||
|
||||
def image_to_bgra(image: "Image", x, y, w, h) -> bytes:
|
||||
buf = bytearray(w * h * image_8bpp.size)
|
||||
index = 0
|
||||
for p in image.crop((x, y, x + w, y + h)).getdata():
|
||||
r = p[0]
|
||||
g = p[1]
|
||||
b = p[2]
|
||||
a = p[3] if len(p) == 4 else 255
|
||||
|
||||
image_8bpp.pack_into(buf, index, b, g, r, a)
|
||||
index += image_8bpp.size
|
||||
|
||||
return buf
|
||||
@@ -1,46 +0,0 @@
|
||||
from cpython.mem cimport PyMem_Malloc, PyMem_Free
|
||||
|
||||
cdef struct ImageRGBA:
|
||||
unsigned char r
|
||||
unsigned char g
|
||||
unsigned char b
|
||||
unsigned char a
|
||||
|
||||
def image_to_bgra(image, long ix, long iy, unsigned long w, unsigned long h):
|
||||
ptr = dict(image.im.unsafe_ptrs)['image32']
|
||||
cdef size_t ptr_int = <size_t> ptr
|
||||
cdef ImageRGBA **image32 = <ImageRGBA**> ptr_int
|
||||
|
||||
if ix < 0:
|
||||
ix = 0
|
||||
if iy < 0:
|
||||
iy = 0
|
||||
if ix + w > image.width:
|
||||
w = image.width - ix
|
||||
if iy + h > image.height:
|
||||
h = image.height - iy
|
||||
|
||||
cdef size_t size = w * h * 4
|
||||
cdef unsigned char *buf = <unsigned char*> PyMem_Malloc(size)
|
||||
|
||||
cdef size_t index, x, y
|
||||
cdef unsigned char r, g, b, a
|
||||
|
||||
with nogil:
|
||||
for y in range(iy, iy+h):
|
||||
for x in range(ix, ix+w):
|
||||
index = (((y-iy) * w) + (x-ix)) * 4
|
||||
r = image32[y][x].r
|
||||
g = image32[y][x].g
|
||||
b = image32[y][x].b
|
||||
a = image32[y][x].a
|
||||
|
||||
buf[index] = b
|
||||
buf[index+1] = g
|
||||
buf[index+2] = r
|
||||
buf[index+3] = a
|
||||
|
||||
try:
|
||||
return buf[:size]
|
||||
finally:
|
||||
PyMem_Free(buf)
|
||||
@@ -0,0 +1,63 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from runekit.game.instance import ImageType
|
||||
|
||||
|
||||
def np_crop(image: np.ndarray, x: int, y: int, w: int, h: int) -> np.ndarray:
|
||||
shape = image.shape
|
||||
img_width = shape[1]
|
||||
img_height = shape[0]
|
||||
if w is None:
|
||||
w = img_width
|
||||
if h is None:
|
||||
h = img_height
|
||||
|
||||
x_pad_left = max(0, -x)
|
||||
y_pad_left = max(0, -y)
|
||||
|
||||
x1 = max(0, x)
|
||||
y1 = max(0, y)
|
||||
x2 = min(x1 + w - x_pad_left, img_width - x_pad_left)
|
||||
y2 = min(y1 + h - y_pad_left, img_height - y_pad_left)
|
||||
image = image[y1:y2, x1:x2]
|
||||
|
||||
x_pad = (x_pad_left, w - (x2 - x1) - x_pad_left)
|
||||
y_pad = (y_pad_left, h - (y2 - y1) - y_pad_left)
|
||||
if x_pad != (0, 0) or y_pad != (0, 0):
|
||||
image = np.pad(image, (y_pad, x_pad, (0, 0)))
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def np_save_image(image: np.ndarray, out: str):
|
||||
from PIL import Image
|
||||
|
||||
size = (image.shape[1], image.shape[0])
|
||||
if len(image.shape) == 2:
|
||||
# Grayscale
|
||||
Image.frombuffer("L", size, image.tobytes(), "raw", "L", 0, 1).save(out)
|
||||
elif image.shape[2] == 3:
|
||||
# RGB
|
||||
Image.frombuffer(
|
||||
"RGB", size, image[:, :, ::-1].tobytes(), "raw", "RGB", 0, 1
|
||||
).save(out)
|
||||
else:
|
||||
# RGBA
|
||||
Image.frombuffer(
|
||||
"RGBA", size, image[:, :, [2, 1, 0, 3]].tobytes(), "raw", "RGBA", 0, 1
|
||||
).save(out)
|
||||
|
||||
|
||||
def ensure_np_image(image: "ImageType") -> np.ndarray:
|
||||
if isinstance(image, np.ndarray):
|
||||
return image
|
||||
|
||||
if image.mode == "RGB":
|
||||
image = image.convert("RGBA")
|
||||
|
||||
out = np.array(image)
|
||||
out = out[:, :, [2, 1, 0, 3]] # B G R A
|
||||
return out
|
||||
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import click
|
||||
from PySide2.QtCore import QSettings, Qt, QTimer
|
||||
from PySide2.QtWidgets import (
|
||||
QApplication,
|
||||
QMessageBox,
|
||||
)
|
||||
|
||||
import runekit._resources
|
||||
from runekit import browser
|
||||
from runekit.game import get_platform_manager
|
||||
from runekit.host import Host
|
||||
|
||||
|
||||
@click.command(
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True,
|
||||
)
|
||||
)
|
||||
@click.option("--game-index", default=0, help="Game instance index, starting from 0")
|
||||
@click.argument("qt_args", nargs=-1, type=click.UNPROCESSED)
|
||||
@click.argument("app_url", required=False)
|
||||
def main(app_url, game_index, qt_args):
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
logging.info("Starting QtWebEngine")
|
||||
browser.init()
|
||||
|
||||
app = QApplication(["runekit", *qt_args])
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
app.setOrganizationName("cupco.de")
|
||||
app.setOrganizationDomain("cupco.de")
|
||||
app.setApplicationName("RuneKit")
|
||||
|
||||
signal.signal(signal.SIGINT, lambda no, frame: app.quit())
|
||||
|
||||
timer = QTimer()
|
||||
timer.start(300)
|
||||
timer.timeout.connect(lambda: None)
|
||||
|
||||
QSettings.setDefaultFormat(QSettings.IniFormat)
|
||||
|
||||
try:
|
||||
game_manager = get_platform_manager()
|
||||
host = Host(game_manager)
|
||||
|
||||
if app_url == "settings":
|
||||
host.open_settings()
|
||||
host.setting_dialog.setAttribute(Qt.WA_DeleteOnClose)
|
||||
host.setting_dialog.destroyed.connect(app.quit)
|
||||
elif app_url:
|
||||
logging.info("Loading app")
|
||||
game_app = host.launch_app_from_url(app_url)
|
||||
game_app.window.destroyed.connect(app.quit)
|
||||
else:
|
||||
if not host.app_store.has_default_apps():
|
||||
host.app_store.load_default_apps()
|
||||
|
||||
app.exec_()
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
msg = QMessageBox(
|
||||
QMessageBox.Critical,
|
||||
"Oh No!",
|
||||
f"Fatal error: \n\n{e.__class__.__name__}: {e}",
|
||||
)
|
||||
msg.setDetailedText(traceback.format_exc())
|
||||
msg.exec_()
|
||||
|
||||
raise
|
||||
finally:
|
||||
if game_manager is not None:
|
||||
logging.debug("Stopping game manager")
|
||||
game_manager.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,41 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import QRect
|
||||
from PySide2.QtWidgets import QMainWindow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from runekit.app import App
|
||||
|
||||
|
||||
class GameSnapMixin(QMainWindow):
|
||||
app: "App"
|
||||
__last_game_pos: QRect
|
||||
|
||||
def snap_to_game(self):
|
||||
rect = self.app.game_instance.get_position()
|
||||
pos = rect.topLeft()
|
||||
if pos.x() < 0:
|
||||
pos.setX(0)
|
||||
if pos.y() < 0:
|
||||
pos.setY(0)
|
||||
|
||||
self.move(pos)
|
||||
self.__last_game_pos = self.app.game_instance.get_position()
|
||||
self.app.game_instance.positionChanged.connect(self._update_game_snap)
|
||||
|
||||
def _update_game_snap(self, game_pos: QRect):
|
||||
# If the top left spot of us fall into game window, then we move with the game
|
||||
tl = self.geometry().topLeft()
|
||||
if not game_pos.contains(tl):
|
||||
self.__last_game_pos = game_pos
|
||||
return
|
||||
|
||||
dx = game_pos.x() - self.__last_game_pos.x()
|
||||
dy = game_pos.y() - self.__last_game_pos.y()
|
||||
|
||||
pos = self.pos()
|
||||
pos.setX(pos.x() + dx)
|
||||
pos.setY(pos.y() + dy)
|
||||
self.move(pos)
|
||||
|
||||
self.__last_game_pos = game_pos
|
||||
@@ -1,13 +1,11 @@
|
||||
import abc
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from typing import Union, Dict
|
||||
|
||||
from PySide2.QtGui import QIcon
|
||||
from PySide2.QtCore import QObject, QSettings
|
||||
from PySide2.QtWidgets import QSystemTrayIcon
|
||||
|
||||
from .tooltip import TooltipManager
|
||||
|
||||
TRAY_ICON = Path(__file__).parent / "trayicon.png"
|
||||
from .tray import tray_icon
|
||||
|
||||
|
||||
class Notifier(abc.ABC):
|
||||
@@ -16,6 +14,11 @@ class Notifier(abc.ABC):
|
||||
...
|
||||
|
||||
|
||||
class StubNotifier(Notifier):
|
||||
def notify(self, text):
|
||||
pass
|
||||
|
||||
|
||||
class TooltipNotifier(Notifier):
|
||||
def __init__(self):
|
||||
self.manager = TooltipManager()
|
||||
@@ -25,26 +28,46 @@ class TooltipNotifier(Notifier):
|
||||
|
||||
|
||||
class TrayIconNotifier(Notifier):
|
||||
def __init__(self):
|
||||
self.icon = QSystemTrayIcon(QIcon(str(TRAY_ICON)))
|
||||
self.icon.show()
|
||||
|
||||
def notify(self, text):
|
||||
if text:
|
||||
self.icon.showMessage("RuneKit", text)
|
||||
tray_icon().showMessage("RuneKit", text)
|
||||
|
||||
|
||||
class AutoNotifier(Notifier):
|
||||
class AutoNotifier(QObject):
|
||||
notifier: Notifier
|
||||
|
||||
METHOD_STUB = 0
|
||||
METHOD_NOTIFICATION = 1
|
||||
METHOD_TOOLTIP = 2
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.settings = QSettings(self)
|
||||
type_ = int(self.settings.value("settings/tooltip", self.METHOD_NOTIFICATION))
|
||||
if (
|
||||
QSystemTrayIcon.isSystemTrayAvailable()
|
||||
and QSystemTrayIcon.supportsMessages()
|
||||
and type_ == self.METHOD_NOTIFICATION
|
||||
):
|
||||
self.notifier = TrayIconNotifier()
|
||||
elif type_ == self.METHOD_TOOLTIP:
|
||||
self.notifier = TooltipNotifier()
|
||||
else:
|
||||
self.notifier = StubNotifier()
|
||||
|
||||
def notify(self, text):
|
||||
self.notifier.notify(text)
|
||||
|
||||
@classmethod
|
||||
def availableMethods(cls) -> Dict[int, str]:
|
||||
out = {
|
||||
cls.METHOD_STUB: "Disabled",
|
||||
cls.METHOD_TOOLTIP: "Cursor tooltip",
|
||||
}
|
||||
if (
|
||||
QSystemTrayIcon.isSystemTrayAvailable()
|
||||
and QSystemTrayIcon.supportsMessages()
|
||||
):
|
||||
self.notifier = TrayIconNotifier()
|
||||
else:
|
||||
self.notifier = TooltipNotifier()
|
||||
out[cls.METHOD_NOTIFICATION] = "Notification"
|
||||
|
||||
def notify(self, text):
|
||||
self.notifier.notify(text)
|
||||
return out
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 941 B |
|
After Width: | Height: | Size: 241 B |
|
After Width: | Height: | Size: 184 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 266 B |
|
After Width: | Height: | Size: 264 B |
|
After Width: | Height: | Size: 248 B |
|
After Width: | Height: | Size: 189 B |
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "default",
|
||||
"name": "Default",
|
||||
"url": "https://runeapps.org/nis/default/",
|
||||
|
||||
"borderTop": 4,
|
||||
"borderLeft": 4,
|
||||
"borderRight": 4,
|
||||
"borderBottom": 4,
|
||||
|
||||
"addressHeight": 28,
|
||||
"titleHeight": 36,
|
||||
|
||||
"fontColor": "192,192,192",
|
||||
"titleColor": "192,192,192",
|
||||
"addressBackColor": "76,78,84"
|
||||
}
|
||||
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 409 B |
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 373 B |
|
After Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 451 B |
|
After Width: | Height: | Size: 448 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,6 +1,6 @@
|
||||
from typing import Union, Optional
|
||||
from typing import Union
|
||||
|
||||
from PySide2.QtCore import Qt, QTimer, Slot, QSize
|
||||
from PySide2.QtCore import Qt, QTimer, Slot
|
||||
from PySide2.QtGui import QCursor
|
||||
from PySide2.QtWidgets import QLabel, QMainWindow
|
||||
|
||||
@@ -15,8 +15,8 @@ class TooltipManager(QMainWindow):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
flags=Qt.Dialog
|
||||
| Qt.X11BypassWindowManagerHint
|
||||
flags=Qt.Popup
|
||||
| Qt.FramelessWindowHint
|
||||
| Qt.WindowStaysOnTopHint
|
||||
| Qt.WindowTransparentForInput
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import Slot, Signal, QCoreApplication
|
||||
from PySide2.QtGui import QIcon
|
||||
from PySide2.QtWidgets import QSystemTrayIcon, QMenu
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from runekit.host import Host
|
||||
|
||||
|
||||
class TrayIcon(QSystemTrayIcon):
|
||||
on_settings = Signal()
|
||||
|
||||
host: "Host"
|
||||
|
||||
def __init__(self, host: "Host"):
|
||||
global _tray_icon
|
||||
super().__init__(QIcon(":/runekit/ui/trayicon.png"))
|
||||
|
||||
_tray_icon = self
|
||||
|
||||
self.host = host
|
||||
self._setup_menu()
|
||||
self.setContextMenu(self.menu)
|
||||
|
||||
def _setup_menu(self):
|
||||
if not hasattr(self, "menu"):
|
||||
self.menu = QMenu("RuneKit")
|
||||
|
||||
self.menu.clear()
|
||||
|
||||
self._setup_app_menu("", self.menu)
|
||||
|
||||
self.menu.addSeparator()
|
||||
self.menu_settings = self.menu.addAction("Settings")
|
||||
self.menu_settings.triggered.connect(self.on_settings)
|
||||
self.menu.addAction(
|
||||
QIcon.fromTheme("application-exit"),
|
||||
"Exit",
|
||||
lambda: QCoreApplication.instance().quit(),
|
||||
)
|
||||
|
||||
def _setup_app_menu(self, path: str, menu: QMenu):
|
||||
for app_id, manifest in self.host.app_store.list_app(path):
|
||||
if manifest is None:
|
||||
# Folder
|
||||
submenu = menu.addMenu(QIcon.fromTheme("folder"), app_id)
|
||||
self._setup_app_menu(app_id, submenu)
|
||||
continue
|
||||
|
||||
app_menu = menu.addAction(manifest["appName"])
|
||||
app_menu.triggered.connect(
|
||||
lambda _=None, app_id=app_id: self.host.launch_app_id(app_id)
|
||||
)
|
||||
app_menu.setToolTip(manifest["description"])
|
||||
|
||||
icon = self.host.app_store.icon(app_id)
|
||||
if icon:
|
||||
app_menu.setIcon(icon)
|
||||
|
||||
@Slot()
|
||||
def update_menu(self):
|
||||
self._setup_menu()
|
||||
|
||||
|
||||
def tray_icon() -> TrayIcon:
|
||||
return _tray_icon
|
||||
@@ -0,0 +1,241 @@
|
||||
from PySide2.QtCore import Signal, QPoint
|
||||
from PySide2.QtGui import QPixmap, Qt, QResizeEvent
|
||||
from PySide2.QtWidgets import (
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QPushButton,
|
||||
)
|
||||
|
||||
SKIN = ":/runekit/ui/skins/default/"
|
||||
|
||||
|
||||
class WindowFrame(QWidget):
|
||||
on_minimize = Signal()
|
||||
on_settings = Signal()
|
||||
on_exit = Signal()
|
||||
|
||||
content: QWidget
|
||||
shade = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._layout()
|
||||
|
||||
def _layout(self):
|
||||
self._inner = _FrameInner(parent=self)
|
||||
self._buttons = _WindowButtons(parent=self)
|
||||
self._buttons.on_minimize.connect(self.on_minimize)
|
||||
self._buttons.on_minimize.connect(self.handle_shade)
|
||||
self._buttons.on_settings.connect(self.on_settings)
|
||||
self._buttons.on_exit.connect(self.on_exit)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setMargin(0)
|
||||
self.setLayout(layout)
|
||||
layout.addWidget(self._inner)
|
||||
self._buttons.show()
|
||||
|
||||
def resizeEvent(self, evt: QResizeEvent):
|
||||
buttons_size = self._buttons.sizeHint()
|
||||
self._buttons.setGeometry(
|
||||
evt.size().width() - buttons_size.width(),
|
||||
0,
|
||||
buttons_size.width(),
|
||||
buttons_size.height(),
|
||||
)
|
||||
|
||||
def set_content(self, content: QWidget):
|
||||
self._inner.set_content(content)
|
||||
self.content = content
|
||||
|
||||
def handle_shade(self):
|
||||
self.set_shade(not self.shade)
|
||||
|
||||
def set_shade(self, shade: bool):
|
||||
if shade == self.shade:
|
||||
return
|
||||
|
||||
self.shade = shade
|
||||
|
||||
if self.shade:
|
||||
self.previous_window_geom = self.window().size()
|
||||
self.previous_window_min_size = self.window().minimumSize()
|
||||
top_right = self.window().geometry().topRight()
|
||||
self._inner.hide()
|
||||
self.window().setMinimumSize(self._buttons.sizeHint())
|
||||
|
||||
size = self._buttons.sizeHint()
|
||||
self.window().resize(size)
|
||||
|
||||
new_top_left = QPoint(top_right)
|
||||
new_top_left.setX(top_right.x() - size.width())
|
||||
self.window().move(new_top_left)
|
||||
else:
|
||||
pos = self.window().geometry().topRight()
|
||||
self._inner.show()
|
||||
if hasattr(self, "previous_window_geom"):
|
||||
self.window().setMinimumSize(self.previous_window_min_size)
|
||||
geom = self.previous_window_geom
|
||||
else:
|
||||
geom = self.window().sizeHint()
|
||||
|
||||
pos.setX(pos.x() - geom.width())
|
||||
self.window().resize(geom)
|
||||
self.window().move(pos)
|
||||
|
||||
|
||||
class _FrameInner(QWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._layout()
|
||||
# TODO: Set background
|
||||
|
||||
def _layout(self):
|
||||
layout = QVBoxLayout()
|
||||
self.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
top_row = QHBoxLayout()
|
||||
layout.addLayout(top_row)
|
||||
top_row.setContentsMargins(0, 0, 0, 0)
|
||||
top_row.setSpacing(0)
|
||||
top_row.addWidget(self.build_image("borderTL", Qt.TopEdge | Qt.LeftEdge))
|
||||
top_row.addWidget(self.build_image("borderT", Qt.TopEdge, h_fix=False))
|
||||
top_row.addWidget(self.build_image("borderTR", Qt.TopEdge | Qt.RightEdge))
|
||||
|
||||
mid_row = QHBoxLayout()
|
||||
layout.addLayout(mid_row)
|
||||
mid_row.setContentsMargins(0, 0, 0, 0)
|
||||
mid_row.setSpacing(0)
|
||||
mid_row.addWidget(self.build_image("borderL", Qt.LeftEdge, v_fix=False))
|
||||
self.content = QWidget(self)
|
||||
mid_row.addWidget(self.content, 1)
|
||||
mid_row.addWidget(self.build_image("borderR", Qt.RightEdge, v_fix=False))
|
||||
|
||||
bot_row = QHBoxLayout()
|
||||
layout.addLayout(bot_row)
|
||||
bot_row.setContentsMargins(0, 0, 0, 0)
|
||||
bot_row.setSpacing(0)
|
||||
bot_row.addWidget(
|
||||
self.build_image(
|
||||
"borderBL",
|
||||
Qt.BottomEdge | Qt.LeftEdge,
|
||||
)
|
||||
)
|
||||
bot_row.addWidget(self.build_image("borderB", Qt.BottomEdge, h_fix=False))
|
||||
bot_row.addWidget(self.build_image("borderBR", Qt.BottomEdge | Qt.RightEdge))
|
||||
|
||||
def build_image(self, name: str, edge=None, h_fix=True, v_fix=True):
|
||||
pixmap = QPixmap(SKIN + name)
|
||||
label = _ResizeHandle(edge, parent=self)
|
||||
label.setSizePolicy(
|
||||
QSizePolicy.Fixed if h_fix else QSizePolicy.Preferred,
|
||||
QSizePolicy.Fixed if v_fix else QSizePolicy.Preferred,
|
||||
)
|
||||
label.setScaledContents(True)
|
||||
label.setPixmap(pixmap)
|
||||
return label
|
||||
|
||||
def set_content(self, content: QWidget):
|
||||
self.layout().replaceWidget(self.content, content)
|
||||
self.content = content
|
||||
|
||||
|
||||
class _WindowButtons(QWidget):
|
||||
on_minimize = Signal()
|
||||
on_settings = Signal()
|
||||
on_exit = Signal()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._layout()
|
||||
|
||||
def _layout(self):
|
||||
layout = QHBoxLayout()
|
||||
self.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addStretch(1)
|
||||
|
||||
drag_area = _DragHandle(self)
|
||||
layout.addWidget(drag_area, alignment=Qt.AlignTop)
|
||||
|
||||
minimize_button = QPushButton(self)
|
||||
minimize_button.clicked.connect(self.on_minimize)
|
||||
minimize_button.setStyleSheet(
|
||||
self.button_style("minimize.png", "minimizeHover.png")
|
||||
)
|
||||
layout.addWidget(minimize_button, alignment=Qt.AlignTop)
|
||||
|
||||
# settings_button = QPushButton(self)
|
||||
# settings_button.clicked.connect(self.on_settings)
|
||||
# settings_button.setStyleSheet(
|
||||
# self.button_style("settings.png", "settingsHover.png")
|
||||
# )
|
||||
# layout.addWidget(settings_button, alignment=Qt.AlignTop)
|
||||
|
||||
exit_button = QPushButton(self)
|
||||
exit_button.clicked.connect(self.on_exit)
|
||||
exit_button.setStyleSheet(self.button_style("exit.png", "exitHover.png"))
|
||||
layout.addWidget(exit_button, alignment=Qt.AlignTop)
|
||||
|
||||
def button_style(self, normal, hover):
|
||||
return f"""
|
||||
QPushButton {{
|
||||
image: url({SKIN}{normal});
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}}
|
||||
QPushButton::hover {{
|
||||
image: url({SKIN}{hover});
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
class _DragHandle(QLabel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.setPixmap(QPixmap(SKIN + "borderDrag.png"))
|
||||
self.setCursor(Qt.OpenHandCursor)
|
||||
|
||||
def mousePressEvent(self, ev):
|
||||
self.window().windowHandle().startSystemMove()
|
||||
|
||||
|
||||
class _ResizeHandle(QLabel):
|
||||
def __init__(self, edge, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.edge = edge
|
||||
|
||||
if edge == Qt.TopEdge & Qt.LeftEdge:
|
||||
self.setCursor(Qt.SizeFDiagCursor)
|
||||
elif edge == Qt.TopEdge:
|
||||
self.setCursor(Qt.SizeVerCursor)
|
||||
elif edge == Qt.TopEdge & Qt.RightEdge:
|
||||
self.setCursor(Qt.SizeBDiagCursor)
|
||||
elif edge == Qt.LeftEdge:
|
||||
self.setCursor(Qt.SizeHorCursor)
|
||||
elif edge == Qt.RightEdge:
|
||||
self.setCursor(Qt.SizeHorCursor)
|
||||
elif edge == Qt.BottomEdge & Qt.LeftEdge:
|
||||
self.setCursor(Qt.SizeBDiagCursor)
|
||||
elif edge == Qt.BottomEdge:
|
||||
self.setCursor(Qt.SizeVerCursor)
|
||||
elif edge == Qt.BottomEdge & Qt.RightEdge:
|
||||
self.setCursor(Qt.SizeFDiagCursor)
|
||||
|
||||
def mousePressEvent(self, ev):
|
||||
if self.edge:
|
||||
self.window().windowHandle().startSystemResize(self.edge)
|
||||