mirror of
https://github.com/bdnugget/runekit.git
synced 2026-11-07 09:07:30 +00:00
Compare commits
76
Commits
No files matched your search
@@ -0,0 +1,53 @@
|
||||
name: AppImage
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-18.04 # Leave this on the earliest supported OS!
|
||||
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: 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 }}
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Mac
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-11
|
||||
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: Install dependencies
|
||||
run: |
|
||||
python -m pip install poetry
|
||||
poetry install
|
||||
- name: Build resources
|
||||
run: poetry run make runekit/_resources.py
|
||||
- name: Build app
|
||||
run: poetry run make dist/RuneKit.app.zip
|
||||
|
||||
# Deploy
|
||||
- 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.app.zip
|
||||
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.app.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Bump tags
|
||||
on: [push]
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Bump tag
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: richardsimko/update-tag@v1
|
||||
with:
|
||||
tag_name: continuous
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Clear old releases
|
||||
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 }}
|
||||
+2
-1
@@ -6,4 +6,5 @@ setup.py
|
||||
*.egg-info
|
||||
__pycache__
|
||||
runekit/_resources.py
|
||||
RuneKitApp.spec
|
||||
*.AppImage
|
||||
*.pyo
|
||||
@@ -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
|
||||
@@ -1,17 +1,47 @@
|
||||
dev: runekit/_resources.py
|
||||
LINUXDEPLOY ?= linuxdeploy-$(shell uname -m).AppImage
|
||||
|
||||
dist/runekit.tar.gz: main.py poetry.lock $(wildcard runekit/**/*)
|
||||
poetry build -f sdist
|
||||
cd dist; cp runekit-*.tar.gz runekit.tar.gz
|
||||
dev: runekit/_resources.py
|
||||
|
||||
runekit/_resources.py: resources.qrc $(wildcard runekit/**/*.js) $(wildcard runekit/**/*.png)
|
||||
pyside2-rcc $< -o $@
|
||||
|
||||
dist/RuneKitApp.app: main.py poetry.lock $(wildcard runekit/**/*)
|
||||
pyinstaller -w -n RuneKitApp \
|
||||
# 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/RuneKit.app: RuneKit.spec main.py poetry.lock runekit/_resources.py $(wildcard runekit/**/*)
|
||||
pyinstaller -w -n RuneKitApp --noconfirm \
|
||||
--exclude-module tkinter \
|
||||
-s -d noarchive \
|
||||
--osx-bundle-identifier de.cupco.runekit \
|
||||
$<
|
||||
|
||||
dist/RuneKit.app.zip: dist/RuneKit.app
|
||||
cd dist; zip -r -9 RuneKit.app.zip RuneKit.app
|
||||
|
||||
# AppImage
|
||||
|
||||
build/python3.9.7.AppImage:
|
||||
mkdir build || true
|
||||
wget https://github.com/niess/python-appimage/releases/download/python3.9/python3.9.7-cp39-cp39-manylinux1_x86_64.AppImage -O "$@"
|
||||
chmod +x "$@"
|
||||
|
||||
build/appdir: build/python3.9.7.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,58 +4,45 @@
|
||||
|
||||
Alt1-compatible toolbox for RuneScape 3, for Linux and macOS.
|
||||
|
||||
## What works
|
||||
**See also [Alt1 Electron](https://github.com/skillbert/alt1-electron) the experimental official Alt1 replacement from the Alt1 authors.**
|
||||
|
||||
| App | Linux | macOS |
|
||||
| ------------------------------------------------------------------------------- | ------ | ------ |
|
||||
| **Clue Solver** | | |
|
||||
| - Text/Emote | ✅ | ✅ |
|
||||
| - Map scroll | ✅ | ? |
|
||||
| - Compass | ✅ | ? |
|
||||
| - Scan | ✅ | ? |
|
||||
| - Celtic knot | ✅ | ✅ |
|
||||
| - Slide | ✅ | ✅ |
|
||||
| - Lockbox | ✅ | ? |
|
||||
| - Towers | ? | ? |
|
||||
| Example app | ✅ | ✅ |
|
||||
| **AfkWarden** | | |
|
||||
| - Inactive | 1 | ✅ |
|
||||
| - XP Counter | ❌ | ❌ |
|
||||
| - Chatbox | ✅ | ✅ |
|
||||
| - Crafting menu | ✅ | ✅ |
|
||||
| - Buffs | ✅ | ✅ |
|
||||
| - Action bar stats | ✅ | ✅ |
|
||||
| - Dialog box | ✅ | ✅ |
|
||||
| - Target death | ? | ? |
|
||||
| - Sheathe stance | ? | ? |
|
||||
| - Castle Wars | ? | ? |
|
||||
| - Fight kiln waves | ? | ? |
|
||||
| - Target death | ? | ? |
|
||||
| - Item drops | ? | ? |
|
||||
| **DgKey** | | |
|
||||
| - Show map | ✅ | ? |
|
||||
| - Track key | ✅ | ? |
|
||||
| - Select player location | ❌ | ? |
|
||||
| **[ArchMatCounter](https://zerogwafa.github.io/ArchMatCounter/appconfig.json)** | ? | ? |
|
||||
| **Bank price checker** | ✅ | ? |
|
||||
* [Compatibility](https://github.com/whs/runekit/wiki/App-Compatibility)
|
||||
* [Troubleshooting](https://github.com/whs/runekit/wiki/Troubleshooting)
|
||||
|
||||
- 1: Only keyboard activity count as active
|
||||
## Installing
|
||||
|
||||
## Installation setup
|
||||
### Linux
|
||||
|
||||
These instructions are for those who have little/no programming experience and prepare your environment to run the instructions in the following section `Running`.
|
||||
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
|
||||
|
||||
- [macOS](./docs/macos-setup.md)
|
||||
- [video guide](https://youtu.be/X03SyyV9Bro) from PupRS
|
||||
### macOS
|
||||
|
||||
## Running
|
||||
1. [Download RuneKit.app](https://github.com/whs/runekit/releases/tag/continuous) and unzip (if you use Safari it should automatically unzip)
|
||||
2. Open Terminal (search in spotlight/launchpad if you can't find it)
|
||||
3. Type `xattr -dr com.apple.quarantine ` (including trailing space) and drop the app onto Terminal so it would be like `yourname@yourmacname ~ % xattr -dr com.apple.quarantine /Users/yourname/Downloads/RuneKit.app`. Press enter.
|
||||
4. Launch the app. The first launch might spring in the dock for a good minute.
|
||||
5. If permission prompt appears, grant it in System Preferences > Security. **Then quit RuneKit (right click dock icon > quit or force quit) and start it again.** Don't quit RuneKit while it is downloading app list! The list of permissions are:
|
||||
- Accessibility - for access to game window
|
||||
- Input Monitoring - for hooking alt+1 and idle detection
|
||||
- Screen Recording - for capturing game
|
||||
6. Once all permission has been granted the application appears as system tray icon (top right)
|
||||
|
||||
## 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.
|
||||
|
||||
```sh
|
||||
# Try this first
|
||||
poetry install
|
||||
# If previous fails and you're on Big Sur, try this instead
|
||||
SYSTEM_VERSION_COMPAT=1 poetry install
|
||||
@@ -68,48 +55,16 @@ poetry run python main.py https://runeapps.org/apps/alt1/afkscape/appconfig.json
|
||||
poetry run python main.py
|
||||
```
|
||||
|
||||
### Linux additional instruction
|
||||
|
||||
Requires libxcb to be installed
|
||||
|
||||
### macOS additional instruction
|
||||
|
||||
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
|
||||
|
||||
## Developer
|
||||
|
||||
Start with `--remote-debugging-port=9222` to enable remote debugger protocol.
|
||||
To debug, go to `chrome://inspect` on Chrome/Chromium.
|
||||
|
||||
## Technical Features
|
||||
### Building .app on Mac
|
||||
|
||||
- [Alt1 Compatibility](compatibility.md)
|
||||
|
||||
| Platform Feature | Linux | macOS |
|
||||
| ----------------------- | ------ | ------ |
|
||||
| **Game Manager** | | |
|
||||
| Instance change signals | ❌ | ❌ |
|
||||
| **Instance** | | |
|
||||
| alt1_pressed | 1 | ✅ |
|
||||
| game_activity | 2 | ✅ |
|
||||
| positionChanged | ✅ | ✅ |
|
||||
| scalingChanged | ❌ | ❌ |
|
||||
| focusChanged | ✅ | ✅ |
|
||||
| set_taskbar_progress | ❌ | ❌ |
|
||||
| Window capture | ✅ | ✅ |
|
||||
|
||||
- 1: Works, but the game also receive the key
|
||||
- 2: Only detect keyboard activity
|
||||
|
||||
## Known issues
|
||||
|
||||
- On macOS some values include the size of window decorator, including the screenshot
|
||||
- This can be tested with AfkScape color picker
|
||||
1. Run the normal build steps
|
||||
2. XCode > Settings > Account and download your dev key
|
||||
- I don't have paid Apple Developer CA to test
|
||||
3. Set codesign_identity in RuneKit.spec or leave it None for ad-hoc sign
|
||||
5. `poetry run make dist/RuneKit.app.zip`
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
block_cipher = None
|
||||
|
||||
|
||||
a = Analysis(['main.py'],
|
||||
pathex=['runekit'],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=['tkinter'],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=True)
|
||||
pyz = PYZ(a.pure, a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
|
||||
exe = EXE(pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='RuneKitApp',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=True,
|
||||
upx=False,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None )
|
||||
coll = COLLECT(exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=True,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name='RuneKit')
|
||||
app = BUNDLE(coll,
|
||||
name='RuneKit.app',
|
||||
icon=None,
|
||||
bundle_identifier='de.cupco.runekit',
|
||||
info_plist={
|
||||
'LSEnvironment': {
|
||||
'LANG': 'en_US.UTF-8',
|
||||
'LC_CTYPE': 'en_US.UTF-8'
|
||||
}
|
||||
})
|
||||
@@ -1,14 +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, "scripts": ["main.py"]})
|
||||
+7
-7
@@ -16,21 +16,21 @@
|
||||
- [x] alt1.rsActive
|
||||
- [x] alt1.userResize
|
||||
- [ ] alt1.identiyAppUrl
|
||||
- [ ] alt1.openBrowser
|
||||
- [ ] alt1.clearBinds
|
||||
- [x] alt1.openBrowser
|
||||
- [x] alt1.clearBinds
|
||||
- [ ] alt1.registerStatusDaemon
|
||||
- [ ] alt1.getStatusDaemonState
|
||||
- [ ] alt1.showNotification
|
||||
- [ ] alt1.closeApp
|
||||
- [x] alt1.showNotification
|
||||
- [x] alt1.closeApp
|
||||
- [x] alt1.setTooltip
|
||||
- [x] alt1.clearTooltip
|
||||
- [x] alt1.setTaskbarProgress
|
||||
- [ ] alt1.setTitleBarText - Implemented but not validated
|
||||
- [ ] alt1.setTitleBarText
|
||||
- [x] alt1.overLayRect
|
||||
- [x] alt1.overLayText
|
||||
- [x] alt1.overLayTextEx
|
||||
- [ ] alt1.overLayLine
|
||||
- [ ] alt1.overLayImage
|
||||
- [x] alt1.overLayLine
|
||||
- [x] alt1.overLayImage
|
||||
- [x] alt1.overLayClearGroup
|
||||
- [x] alt1.overLaySetGroup
|
||||
- [x] alt1.overLayFreezeGroup
|
||||
|
||||
@@ -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;
|
||||
Executable
+12
@@ -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 $@
|
||||
+13
-5
@@ -1,12 +1,20 @@
|
||||
# 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
|
||||
### (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.
|
||||
|
||||
@@ -24,7 +32,7 @@ You should now find the folder in your `Downloads` folder (use `Finder` to find
|
||||
|
||||
Great, we have the code! Next step please.
|
||||
|
||||
## (2/4) Installing Homebrew (a package manager)
|
||||
### (2/4) Installing Homebrew (a package manager)
|
||||
|
||||
Basically, follow the instructions on their (Homebrew's) website [here](https://brew.sh/).
|
||||
|
||||
@@ -38,7 +46,7 @@ You can check that this worked by closing and reopening your terminal, then runn
|
||||
|
||||
Awesome, we'll need this tool later. For now, on to Python.
|
||||
|
||||
## (3/4) Installing Python (3.9.1)
|
||||
### (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).
|
||||
|
||||
@@ -46,7 +54,7 @@ Alright, we're going to install Python the straightforward way. We're going to d
|
||||
|
||||
If you're having trouble finding the downloads at the link, remember to scroll to the bottom of the page.
|
||||
|
||||
## (4/4) Installing poetry
|
||||
### (4/4) Installing poetry
|
||||
|
||||
Run the following command in your terminal:
|
||||
|
||||
@@ -54,7 +62,7 @@ Run the following command in your terminal:
|
||||
brew install poetry
|
||||
```
|
||||
|
||||
## One last thing...
|
||||
### 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:
|
||||
|
||||
|
||||
@@ -1,94 +1,4 @@
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import click
|
||||
from PySide2.QtWidgets import (
|
||||
QApplication,
|
||||
QMessageBox,
|
||||
QInputDialog,
|
||||
QDialog,
|
||||
)
|
||||
|
||||
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)
|
||||
game_manager = None
|
||||
try:
|
||||
host = Host()
|
||||
|
||||
if app_url == "settings":
|
||||
settings_wnd = host.open_settings()
|
||||
settings_wnd.destroyed.connect(app.quit)
|
||||
else:
|
||||
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))
|
||||
if len(game_instances) == 0:
|
||||
QMessageBox(
|
||||
QMessageBox.Critical,
|
||||
"Game not found",
|
||||
"Cannot find RuneScape. Launch the game first",
|
||||
).exec_()
|
||||
return
|
||||
|
||||
game = game_instances[game_index]
|
||||
|
||||
if app_url is None:
|
||||
dialog = QInputDialog()
|
||||
dialog.setComboBoxEditable(True)
|
||||
dialog.setComboBoxItems(
|
||||
[
|
||||
"https://runeapps.org/apps/alt1/example/appconfig.json",
|
||||
"https://runeapps.org/apps/clue/appconfig.json",
|
||||
"https://runeapps.org/apps/alt1/afkscape/appconfig.json",
|
||||
]
|
||||
)
|
||||
dialog.setLabelText("Enter appconfig URL")
|
||||
dialog.setWindowTitle("RuneKit")
|
||||
if dialog.exec_() == QDialog.DialogCode.Rejected:
|
||||
return
|
||||
app_url = dialog.textValue()
|
||||
|
||||
logging.info("Loading app")
|
||||
game_app = host.start_app(app_url, game)
|
||||
game_app.window.destroyed.connect(app.quit)
|
||||
|
||||
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:
|
||||
game_manager.stop()
|
||||
|
||||
from runekit.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Generated
+366
-622
File diff suppressed because it is too large.
Load diff
+11
-15
@@ -4,29 +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"
|
||||
requests = "^2.26.0"
|
||||
PySide2 = "^5.15.2"
|
||||
Pillow = "^8.1.0"
|
||||
Pillow = "^8.3.1"
|
||||
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"
|
||||
pyobjc-framework-Quartz = [{ version="7.3", platform="darwin" }]
|
||||
pyobjc-framework-ApplicationServices = [{ version="7.3", platform="darwin" }]
|
||||
pyobjc-framework-CoreText = [{ version="7.3", platform="darwin" }]
|
||||
click = "^8.0.1"
|
||||
psutil = "^5.8.0"
|
||||
opencv-python-headless = "^4.5.3.56"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
Cython = "^0.29.21"
|
||||
black = "^20.8b1"
|
||||
briefcase = "^0.3.4"
|
||||
pyinstaller = "^4.2"
|
||||
pyinstaller = [{ version="^4.5.1", 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'
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Used for Platypus boot
|
||||
export LC_ALL=en_US.UTF-8
|
||||
export LANG=en_US.UTF-8
|
||||
./RuneKitApp/RuneKitApp
|
||||
|
||||
@@ -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
|
||||
+20
-1
@@ -13,18 +13,34 @@ 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
|
||||
@@ -51,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"])
|
||||
|
||||
@@ -58,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"])
|
||||
|
||||
@@ -65,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,20 +1,67 @@
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import Qt, Slot, QRect, QObject, Signal, QEvent
|
||||
from PySide2.QtGui import QIcon, QCloseEvent
|
||||
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)
|
||||
|
||||
# This breaks AFK Warden popups
|
||||
# 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):
|
||||
@@ -22,6 +69,7 @@ class BrowserWindow(QMainWindow):
|
||||
self.app = app
|
||||
|
||||
if self.framed:
|
||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||
self.frame = WindowFrame(parent=self)
|
||||
self.frame.on_exit.connect(self.close)
|
||||
self.setCentralWidget(self.frame)
|
||||
@@ -38,12 +86,16 @@ class BrowserWindow(QMainWindow):
|
||||
else:
|
||||
self.setCentralWidget(self.browser)
|
||||
|
||||
page = QWebEnginePage(self.app.get_web_profile(), 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):
|
||||
@@ -57,6 +109,27 @@ 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:
|
||||
@@ -65,7 +138,15 @@ class BrowserView(QWebEngineView):
|
||||
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:
|
||||
|
||||
+15
-39
@@ -2,9 +2,8 @@ import logging
|
||||
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,18 +13,20 @@ class AppWindow(BrowserWindow):
|
||||
logger: logging.Logger
|
||||
app_icon: Optional[QIcon]
|
||||
|
||||
framed = sys.platform != "darwin"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# TODO: Hide from taskbar/group this as part of one big window?
|
||||
flags = Qt.NoDropShadowWindowHint | Qt.WindowStaysOnTopHint
|
||||
self.settings = QSettings()
|
||||
super().__init__(**kwargs)
|
||||
self.settings.setParent(self)
|
||||
|
||||
# TODO: Hide from taskbar/group this as part of one big window?
|
||||
flags = Qt.NoDropShadowWindowHint | Qt.WindowStaysOnTopHint | Qt.Window
|
||||
if self.framed:
|
||||
flags |= Qt.CustomizeWindowHint
|
||||
self.setWindowFlags(flags)
|
||||
|
||||
super().__init__(flags=flags, **kwargs)
|
||||
self.pool = QThreadPool(parent=self)
|
||||
self.setWindowTitle(self.app.manifest["appName"])
|
||||
self.snap_to_game()
|
||||
|
||||
self.logger = logging.getLogger(
|
||||
__name__
|
||||
@@ -36,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" and sys.platform != "darwin"
|
||||
|
||||
def minimumSize(self) -> QSize:
|
||||
return QSize(self.app.manifest["minWidth"], self.app.manifest["minHeight"])
|
||||
@@ -61,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)
|
||||
+69
-10
@@ -40,9 +40,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -52,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++){
|
||||
@@ -179,11 +208,13 @@
|
||||
});
|
||||
},
|
||||
|
||||
// openBrowser(url) {
|
||||
// },
|
||||
openBrowser(url) {
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
// clearBinds() {
|
||||
// },
|
||||
clearBinds() {
|
||||
window.alt1.clearTooltip();
|
||||
},
|
||||
|
||||
// registerStatusDaemon(serverUrl, state) {
|
||||
// },
|
||||
@@ -191,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) {
|
||||
@@ -216,7 +251,7 @@
|
||||
},
|
||||
|
||||
setTitleBarText(text) {
|
||||
document.title = text;
|
||||
|
||||
},
|
||||
|
||||
overLayRect(color, x, y, w, h, time, lineWidth) {
|
||||
@@ -241,8 +276,10 @@
|
||||
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) {
|
||||
channel.then(function(chan){
|
||||
@@ -315,6 +352,28 @@
|
||||
// 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(){
|
||||
},
|
||||
};
|
||||
|
||||
+67
-14
@@ -1,7 +1,8 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
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 PySide2.QtCore import (
|
||||
@@ -26,12 +27,19 @@ 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
|
||||
|
||||
|
||||
class BoundedRegion(NamedTuple):
|
||||
image: ImageType
|
||||
|
||||
|
||||
class Alt1Api(QObject):
|
||||
app: "App"
|
||||
rpc_funcs: Dict[str, Callable]
|
||||
@@ -39,7 +47,7 @@ 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
|
||||
@@ -50,11 +58,20 @@ class Alt1Api(QObject):
|
||||
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()
|
||||
@@ -188,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):
|
||||
@@ -208,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", ignore_limit=True)
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -223,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"""
|
||||
@@ -264,6 +314,16 @@ class Alt1Api(QObject):
|
||||
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
|
||||
@@ -451,14 +511,7 @@ class RuneKitSchemeHandler(QWebEngineUrlSchemeHandler):
|
||||
|
||||
processor = RuneKitRequestProcess(self, req, parent=req)
|
||||
processor.signals.successSignal.connect(self.on_success)
|
||||
if "overLay" in req.requestUrl().path():
|
||||
# FIXME: RuneKitRequestProcess should be able to notify main thread that
|
||||
# this should run in main thread
|
||||
|
||||
# Overlay functions require main thread
|
||||
processor.run()
|
||||
else:
|
||||
self.thread_pool.start(processor)
|
||||
self.thread_pool.start(processor)
|
||||
|
||||
@Slot(QWebEngineUrlRequestJob, bytes, bytes)
|
||||
def on_success(self, request, content_type, reply):
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import sys
|
||||
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
|
||||
from PySide2.QtGui import QFont, QPen, QImage, QPixmap
|
||||
from PySide2.QtWidgets import (
|
||||
QGraphicsItem,
|
||||
QGraphicsDropShadowEffect,
|
||||
@@ -12,6 +14,7 @@ from PySide2.QtWidgets import (
|
||||
QGraphicsRectItem,
|
||||
QGraphicsItemGroup,
|
||||
QGraphicsLineItem,
|
||||
QGraphicsPixmapItem,
|
||||
)
|
||||
|
||||
from .utils import decode_color
|
||||
@@ -223,6 +226,10 @@ class OverlayApi(QObject):
|
||||
gfx = QGraphicsTextItem(message)
|
||||
gfx.setDefaultTextColor(decode_color(color))
|
||||
|
||||
if font_name == "" and sys.platform == "darwin":
|
||||
# Don't use Helvetica on Mac
|
||||
font_name = "Verdana"
|
||||
|
||||
font = QFont(font_name, min(50, size))
|
||||
font.setStyleHint(QFont.SansSerif)
|
||||
gfx.setFont(font)
|
||||
@@ -243,7 +250,21 @@ class OverlayApi(QObject):
|
||||
|
||||
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)
|
||||
@@ -2,7 +2,11 @@ import secrets
|
||||
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
|
||||
@@ -16,12 +20,17 @@ class WebProfile(QWebEngineProfile):
|
||||
app: "App"
|
||||
|
||||
def __init__(self, app=None, parent=None):
|
||||
# TODO: Persist
|
||||
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(
|
||||
|
||||
+72
-13
@@ -1,8 +1,13 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -16,24 +21,71 @@ class ApiPermissionDeniedException(Exception):
|
||||
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"
|
||||
ImgTypeG = TypeVar("T", np.ndarray, Image.Image)
|
||||
|
||||
if width is None:
|
||||
width = image.width
|
||||
if height is None:
|
||||
height = image.height
|
||||
|
||||
if width * height * 4 > TRANSFER_LIMIT:
|
||||
return ""
|
||||
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")
|
||||
|
||||
if image.mode == "RGB":
|
||||
image = image.convert("RGBA")
|
||||
return image
|
||||
|
||||
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 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",
|
||||
ignore_limit=False,
|
||||
) -> 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 not ignore_limit and 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:
|
||||
@@ -46,3 +98,10 @@ def decode_color(color: int) -> QColor:
|
||||
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,16 +1,20 @@
|
||||
import abc
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Literal, Optional
|
||||
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):
|
||||
@@ -64,16 +68,20 @@ class GameInstance(QObject):
|
||||
world = Property(int, get_world, notify=worldChanged)
|
||||
|
||||
@abc.abstractmethod
|
||||
def grab_game(self) -> Image:
|
||||
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))
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import abc
|
||||
from typing import List
|
||||
from typing import List, Union
|
||||
|
||||
from PySide2.QtCore import QObject, Property, Signal
|
||||
|
||||
@@ -12,8 +12,12 @@ 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)
|
||||
|
||||
|
||||
+19
-1
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Callable, Tuple, Dict
|
||||
|
||||
from PySide2.QtCore import Qt, QRect
|
||||
import numpy as np
|
||||
from PySide2.QtCore import Qt, QRect, QTimer
|
||||
from PySide2.QtGui import QGuiApplication, QPen
|
||||
from PySide2.QtWidgets import (
|
||||
QMainWindow,
|
||||
@@ -10,6 +12,9 @@ from PySide2.QtWidgets import (
|
||||
QGraphicsRectItem,
|
||||
)
|
||||
|
||||
from .qt import qpixmap_to_np
|
||||
from ..image import is_color_percent_gte
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .instance import GameInstance
|
||||
|
||||
@@ -27,9 +32,11 @@ class DesktopWideOverlay(QMainWindow):
|
||||
| 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)
|
||||
@@ -88,3 +95,14 @@ class DesktopWideOverlay(QMainWindow):
|
||||
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)
|
||||
if is_color_percent_gte(image, color=[0, 0, 0], percent=0.95):
|
||||
self.logger.warning("Detected black screen condition. Disabling overlay")
|
||||
self.hide()
|
||||
File renamed without changes.
+32
-8
@@ -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,30 +42,30 @@ 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
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import ApplicationServices
|
||||
from PySide2.QtWidgets import QGraphicsItem
|
||||
|
||||
from ..instance import GameInstance
|
||||
from ..psutil import PsUtilNetStat
|
||||
from ..psutil_mixins import PsUtilNetStat
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import QuartzGameManager
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import time
|
||||
from functools import reduce
|
||||
from typing import List, Dict, Optional
|
||||
from typing import List, Dict, Optional, Union
|
||||
|
||||
import Quartz
|
||||
import ApplicationServices
|
||||
@@ -61,9 +61,14 @@ class QuartzGameManager(GameManager):
|
||||
|
||||
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, lambda: self.overlay.show())
|
||||
QTimer.singleShot(1000, start)
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
@@ -88,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 list(self._instances.values())[0]
|
||||
|
||||
def get_instance_by_pid(self, pid: int) -> Optional[QuartzGameInstance]:
|
||||
for instance in self._instances.values():
|
||||
if instance.pid == pid:
|
||||
|
||||
@@ -9,7 +9,7 @@ from PySide2.QtGui import QWindow, QGuiApplication
|
||||
from PySide2.QtWidgets import QGraphicsItem
|
||||
|
||||
from runekit.game.instance import GameInstance
|
||||
from runekit.game.psutil import PsUtilNetStat
|
||||
from runekit.game.psutil_mixins import PsUtilNetStat
|
||||
from runekit.game.qt import QtGrabMixin, QtEmbedMixin
|
||||
from .ximage import zpixmap_shm_to_image
|
||||
|
||||
@@ -31,6 +31,7 @@ class X11GameInstance(QtGrabMixin, QtEmbedMixin, PsUtilNetStat, GameInstance):
|
||||
|
||||
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)
|
||||
@@ -53,7 +54,7 @@ class X11GameInstance(QtGrabMixin, QtEmbedMixin, PsUtilNetStat, GameInstance):
|
||||
self.wid, xcffib.composite.Redirect.Automatic
|
||||
)
|
||||
self.name_pixmap()
|
||||
self.manager.connection.core.ChangeWindowAttributes(
|
||||
self.manager.connection.core.ChangeWindowAttributesChecked(
|
||||
self.wid,
|
||||
xcffib.xproto.CW.EventMask,
|
||||
[
|
||||
@@ -96,14 +97,20 @@ class X11GameInstance(QtGrabMixin, QtEmbedMixin, PsUtilNetStat, GameInstance):
|
||||
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.wid
|
||||
self._is_focused = self.manager.get_active_window() == self.wid
|
||||
|
||||
def set_taskbar_progress(self, type_, progress):
|
||||
# TODO: Implement this on Unity? I don't use Unity
|
||||
|
||||
+95
-22
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
from typing import List, Dict, Tuple, Union
|
||||
|
||||
@@ -8,7 +9,7 @@ import xcffib.composite
|
||||
import xcffib.shm
|
||||
import xcffib.xinput
|
||||
import xcffib.xproto
|
||||
from PySide2.QtCore import QThread, Slot, QObject
|
||||
from PySide2.QtCore import QThread, Slot, QObject, Signal
|
||||
|
||||
from runekit.game import GameManager
|
||||
from .instance import GameInstance, X11GameInstance
|
||||
@@ -16,6 +17,7 @@ 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):
|
||||
@@ -31,6 +33,7 @@ class X11GameManager(GameManager):
|
||||
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)
|
||||
@@ -45,9 +48,13 @@ class X11GameManager(GameManager):
|
||||
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()
|
||||
@@ -55,29 +62,56 @@ class X11GameManager(GameManager):
|
||||
|
||||
def get_instances(self) -> List[GameInstance]:
|
||||
def visit(wid: int):
|
||||
wm_class = self.get_property(wid, xcffib.xproto.Atom.WM_CLASS)
|
||||
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
|
||||
|
||||
if wm_class:
|
||||
instance_name, app_name = wm_class.split("\00")
|
||||
if app_name == "RuneScape":
|
||||
if wid not in self._instances:
|
||||
instance = X11GameInstance(self, wid, parent=self)
|
||||
self._instances[wid] = instance
|
||||
|
||||
query = self.connection.core.QueryTree(wid).reply()
|
||||
for child in query.children:
|
||||
visit(child)
|
||||
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)
|
||||
@@ -145,8 +179,27 @@ class X11GameManager(GameManager):
|
||||
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):
|
||||
create_signal = Signal(xcffib.Event)
|
||||
destroy_signal = Signal(int)
|
||||
|
||||
def __init__(self, manager: "X11GameManager", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.manager = manager
|
||||
@@ -157,17 +210,21 @@ class X11EventWorker(QObject):
|
||||
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.active_win_id = self.manager.get_active_window()
|
||||
|
||||
@Slot()
|
||||
def run(self):
|
||||
self.manager.connection.core.ChangeWindowAttributes(
|
||||
self.manager.connection.core.ChangeWindowAttributesChecked(
|
||||
self.manager.screen.root,
|
||||
xcffib.xproto.CW.EventMask,
|
||||
[xcffib.xproto.EventMask.PropertyChange],
|
||||
is_checked=True,
|
||||
)
|
||||
[
|
||||
xcffib.xproto.EventMask.PropertyChange
|
||||
| xcffib.xproto.EventMask.SubstructureNotify,
|
||||
],
|
||||
).check()
|
||||
current_thread = QThread.currentThread()
|
||||
|
||||
while True:
|
||||
@@ -189,7 +246,11 @@ class X11EventWorker(QObject):
|
||||
)
|
||||
|
||||
def on_property_change(self, evt: xcffib.xproto.PropertyNotifyEvent):
|
||||
if evt.atom == self.manager.get_atom(NET_ACTIVE_WINDOW):
|
||||
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:
|
||||
@@ -200,8 +261,8 @@ class X11EventWorker(QObject):
|
||||
for id_, instance in self.manager._instances.items():
|
||||
active = active_win_id == id_
|
||||
|
||||
if active != instance.is_focused:
|
||||
instance.is_focused = active
|
||||
if active != instance._is_focused:
|
||||
instance._is_focused = active
|
||||
instance.focusChanged.emit(active)
|
||||
|
||||
def on_input_event(
|
||||
@@ -216,6 +277,18 @@ class X11EventWorker(QObject):
|
||||
try:
|
||||
self.manager._instances[evt.window].config_signal.emit(evt)
|
||||
except KeyError:
|
||||
self.logger.debug(
|
||||
"Got configure event for %d but is not registered", evt.window
|
||||
)
|
||||
pass
|
||||
|
||||
def on_create(self, evt: xcffib.xproto.CreateNotifyEvent):
|
||||
if evt.window in self.manager._instances:
|
||||
return
|
||||
|
||||
# At this point we aren't sure if it's actually the game...
|
||||
if not self.manager.is_game(evt.window):
|
||||
return
|
||||
|
||||
self.create_signal.emit(evt)
|
||||
|
||||
def on_destroy(self, evt: xcffib.xproto.DestroyNotifyEvent):
|
||||
if evt.window in self.manager._instances:
|
||||
self.destroy_signal.emit(evt.window)
|
||||
@@ -1,16 +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
|
||||
) -> Image:
|
||||
out = Image.frombytes(
|
||||
"RGBA",
|
||||
(width, height),
|
||||
shm.read(size),
|
||||
"raw",
|
||||
)
|
||||
b, g, r, a = out.split()
|
||||
a = ImageOps.invert(a)
|
||||
return Image.merge("RGBA", (r, g, b, a))
|
||||
) -> np.ndarray:
|
||||
out = np.frombuffer(shm.read(size), "<B").copy()
|
||||
out.shape = (height, width, 4)
|
||||
out[:, :, 3] = 0xFF # Convert BGRX to BGRA
|
||||
|
||||
return out
|
||||
@@ -1,43 +0,0 @@
|
||||
from PySide2.QtCore import QAbstractItemModel, Qt, QModelIndex
|
||||
|
||||
|
||||
class ApplicationModel(QAbstractItemModel):
|
||||
h_columns = ("Name", "URL", "Description")
|
||||
|
||||
def columnCount(self, parent: QModelIndex) -> int:
|
||||
return len(self.h_columns)
|
||||
|
||||
def rowCount(self, parent: QModelIndex) -> int:
|
||||
if parent.isValid():
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
def data(self, index: QModelIndex, role: int):
|
||||
if role != Qt.DisplayRole:
|
||||
return None
|
||||
|
||||
col = index.column()
|
||||
if col == 0:
|
||||
return "TestApp"
|
||||
elif col == 1:
|
||||
return "https://runeapps.org/apps/alt1/example/appconfig.json"
|
||||
elif col == 2:
|
||||
return "Lorem ipsum dolor sit amet"
|
||||
|
||||
def index(self, row: int, column: int, parent: QModelIndex) -> QModelIndex:
|
||||
return self.createIndex(row, column)
|
||||
|
||||
def parent(self, index: QModelIndex) -> QModelIndex:
|
||||
return QModelIndex()
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
|
||||
return Qt.ItemIsSelectable | Qt.ItemIsEnabled
|
||||
|
||||
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]
|
||||
@@ -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
|
||||
+96
-14
@@ -1,34 +1,116 @@
|
||||
from typing import TYPE_CHECKING
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from runekit.alt1.utils import fetch_manifest
|
||||
from runekit.app import App
|
||||
from PySide2.QtCore import Slot, QCoreApplication
|
||||
from PySide2.QtWidgets import QMessageBox
|
||||
|
||||
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:
|
||||
"""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().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):
|
||||
wnd = SettingsDialog(self)
|
||||
wnd.show()
|
||||
return wnd
|
||||
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)
|
||||
+133
-16
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import QSize, Qt, Slot, QTimer
|
||||
from PySide2.QtCore import QSize, Qt, Slot, QTimer, QSettings
|
||||
from PySide2.QtGui import QIcon
|
||||
from PySide2.QtWidgets import (
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
@@ -14,10 +16,15 @@ from PySide2.QtWidgets import (
|
||||
QTableWidget,
|
||||
QHBoxLayout,
|
||||
QTreeView,
|
||||
QSpinBox,
|
||||
QPushButton,
|
||||
QAbstractItemView,
|
||||
QInputDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
|
||||
from .applications import ApplicationModel
|
||||
from ..ui import TooltipNotifier, TrayIconNotifier
|
||||
from .appstore_model import AppStoreModel
|
||||
from ..ui import TooltipNotifier, TrayIconNotifier, AutoNotifier
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .host import Host
|
||||
@@ -26,7 +33,6 @@ if TYPE_CHECKING:
|
||||
class SettingsDialog(QMainWindow):
|
||||
def __init__(self, host: "Host", **kwargs):
|
||||
super().__init__(flags=Qt.Dialog, **kwargs)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose)
|
||||
self.host = host
|
||||
self._layout()
|
||||
self.setWindowTitle("RuneKit Settings")
|
||||
@@ -40,7 +46,7 @@ class SettingsDialog(QMainWindow):
|
||||
self.setContentsMargins(11, 11, 11, 11)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(500, 300)
|
||||
return QSize(800, 500)
|
||||
|
||||
|
||||
class ApplicationPage(QWidget):
|
||||
@@ -49,17 +55,97 @@ class ApplicationPage(QWidget):
|
||||
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)
|
||||
|
||||
tree = QTreeView(self)
|
||||
model = ApplicationModel(self)
|
||||
tree.setModel(model)
|
||||
tree.setUniformRowHeights(True)
|
||||
layout.addWidget(tree, 1)
|
||||
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):
|
||||
@@ -68,30 +154,51 @@ class InterfacePage(QWidget):
|
||||
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)
|
||||
tooltip_field.insertItem(0, "Disabled")
|
||||
tooltip_field.insertItem(1, "Notification") # XXX: Check support
|
||||
tooltip_field.insertItem(2, "Cursor tooltip")
|
||||
|
||||
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(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):
|
||||
if index == 1:
|
||||
type_ = list(AutoNotifier.availableMethods().items())[index][0]
|
||||
if type_ == AutoNotifier.METHOD_NOTIFICATION:
|
||||
notifier = TrayIconNotifier()
|
||||
elif index == 2:
|
||||
elif type_ == AutoNotifier.METHOD_TOOLTIP:
|
||||
notifier = TooltipNotifier()
|
||||
else:
|
||||
return
|
||||
@@ -102,3 +209,13 @@ class InterfacePage(QWidget):
|
||||
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 +1 @@
|
||||
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
|
||||
from .algo import is_color_percent_gte
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def is_color_percent_gte(image: np.ndarray, color: List, percent: float) -> bool:
|
||||
"""Check that the image has at least percent% of the given color"""
|
||||
black_pixels = np.count_nonzero(np.all(image[:, :, : len(color)] == color, axis=-1))
|
||||
total_pixels = image.shape[0] * image.shape[1]
|
||||
color_percent = black_pixels / total_pixels
|
||||
return color_percent >= percent
|
||||
@@ -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
|
||||
+39
-13
@@ -1,10 +1,11 @@
|
||||
import abc
|
||||
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
|
||||
from .tray import tray_icon
|
||||
|
||||
|
||||
class Notifier(abc.ABC):
|
||||
@@ -13,6 +14,11 @@ class Notifier(abc.ABC):
|
||||
...
|
||||
|
||||
|
||||
class StubNotifier(Notifier):
|
||||
def notify(self, text):
|
||||
pass
|
||||
|
||||
|
||||
class TooltipNotifier(Notifier):
|
||||
def __init__(self):
|
||||
self.manager = TooltipManager()
|
||||
@@ -22,26 +28,46 @@ class TooltipNotifier(Notifier):
|
||||
|
||||
|
||||
class TrayIconNotifier(Notifier):
|
||||
def __init__(self):
|
||||
self.icon = QSystemTrayIcon(QIcon(":/runekit/ui/trayicon.png"))
|
||||
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
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
from PySide2.QtCore import Signal
|
||||
from PySide2.QtCore import Signal, QPoint
|
||||
from PySide2.QtGui import QPixmap, Qt, QResizeEvent
|
||||
from PySide2.QtWidgets import (
|
||||
QWidget,
|
||||
@@ -18,6 +18,7 @@ class WindowFrame(QWidget):
|
||||
on_exit = Signal()
|
||||
|
||||
content: QWidget
|
||||
shade = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -28,6 +29,7 @@ class WindowFrame(QWidget):
|
||||
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)
|
||||
|
||||
@@ -51,6 +53,41 @@ class WindowFrame(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):
|
||||
@@ -138,12 +175,12 @@ class _WindowButtons(QWidget):
|
||||
)
|
||||
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)
|
||||
# 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)
|
||||
|
||||
Reference in new issue
Block a user