mirror of
https://github.com/bdnugget/runekit.git
synced 2026-11-07 09:07:30 +00:00
Compare 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
|
||||
@@ -41,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,17 +4,45 @@
|
||||
|
||||
Alt1-compatible toolbox for RuneScape 3, for Linux and macOS.
|
||||
|
||||
* [Compatibility](https://github.com/whs/runekit/wiki/App-Compatibility)
|
||||
* [macOS installation guide](docs/macos-setup.md)
|
||||
**See also [Alt1 Electron](https://github.com/skillbert/alt1-electron) the experimental official Alt1 replacement from the Alt1 authors.**
|
||||
|
||||
## Running
|
||||
* [Compatibility](https://github.com/whs/runekit/wiki/App-Compatibility)
|
||||
* [Troubleshooting](https://github.com/whs/runekit/wiki/Troubleshooting)
|
||||
|
||||
## Installing
|
||||
|
||||
### Linux
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
@@ -27,24 +55,17 @@ 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.
|
||||
|
||||
### Building .app on Mac
|
||||
|
||||
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
|
||||
|
||||
This project is [No Maintenance Intended](https://unmaintained.tech/).
|
||||
|
||||
@@ -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"]})
|
||||
@@ -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 $@
|
||||
@@ -1,99 +1,4 @@
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import click
|
||||
from PySide2.QtWidgets import (
|
||||
QApplication,
|
||||
QMessageBox,
|
||||
QInputDialog,
|
||||
QDialog,
|
||||
QSystemTrayIcon,
|
||||
)
|
||||
|
||||
import runekit._resources
|
||||
from runekit import browser
|
||||
from runekit.game import get_platform_manager
|
||||
from runekit.host import Host
|
||||
from runekit.ui.tray import TrayIcon
|
||||
|
||||
|
||||
@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:
|
||||
if QSystemTrayIcon.isSystemTrayAvailable():
|
||||
TrayIcon().show()
|
||||
|
||||
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
+349
-408
File diff suppressed because it is too large.
Load diff
+11
-16
@@ -4,30 +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 = "^4.5.1"
|
||||
opencv-python-headless = "^4.5.1"
|
||||
opencv-python-headless = "^4.5.3.56"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
Cython = "^0.29.21"
|
||||
black = "^20.8b1"
|
||||
pyinstaller = [{ version="^4.2", platform="darwin" }]
|
||||
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,4 +1,5 @@
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import Qt, Slot, QRect, QObject, QUrl
|
||||
@@ -7,6 +8,7 @@ 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:
|
||||
@@ -41,21 +43,22 @@ class PageClass(QWebEnginePage):
|
||||
if not is_main_frame:
|
||||
return super().acceptNavigationRequest(url, type_, is_main_frame)
|
||||
|
||||
if (
|
||||
type_ != QWebEnginePage.NavigationTypeTyped
|
||||
and url.authority() != self.url().authority()
|
||||
):
|
||||
# The web is only allowed pages on the same origin
|
||||
# Cross origin pages would open in browser
|
||||
if url.scheme() in ("http", "https"):
|
||||
QDesktopServices.openUrl(url)
|
||||
# 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 False
|
||||
|
||||
return super().acceptNavigationRequest(url, type_, is_main_frame)
|
||||
|
||||
|
||||
class BrowserWindow(QMainWindow):
|
||||
class BrowserWindow(GameSnapMixin, QMainWindow):
|
||||
app: "App"
|
||||
browser: QWebEngineView
|
||||
page_class = PageClass
|
||||
@@ -66,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)
|
||||
|
||||
+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)
|
||||
+52
-1
@@ -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++){
|
||||
@@ -321,8 +350,30 @@
|
||||
// return -1;
|
||||
// },
|
||||
// bindFindSubImg(id, imgstr, imgwidth, x, y, w, h) {
|
||||
// return syncRpc({func: 'bindFindSubImg', id: id, subimg: imgstr, img_width: imgwidth, x: x, y: y, w: w, h: 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(){
|
||||
},
|
||||
};
|
||||
|
||||
+20
-22
@@ -2,7 +2,6 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Dict, Callable, List, NamedTuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
@@ -31,8 +30,6 @@ from runekit.browser.utils import (
|
||||
decode_image,
|
||||
)
|
||||
from runekit.game.instance import ImageType
|
||||
from runekit.image.cv import find_subimages
|
||||
from runekit.image.np_utils import ensure_np_image, np_crop
|
||||
from runekit.ui.tray import tray_icon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -71,9 +68,10 @@ class Alt1Api(QObject):
|
||||
|
||||
self.rpc_funcs = {
|
||||
"getRegion": self.get_region,
|
||||
"getRegionRaw": self.get_region_raw,
|
||||
"bindRegion": self.bind_region,
|
||||
"bindGetRegion": self.bind_get_region,
|
||||
"bindFindSubImg": self.bind_find_subimage,
|
||||
"bindGetRegionRaw": self.bind_get_region_raw,
|
||||
}
|
||||
|
||||
self._update_screen_info()
|
||||
@@ -207,7 +205,17 @@ 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"):
|
||||
@@ -231,32 +239,22 @@ class Alt1Api(QObject):
|
||||
self.logger.warning("bindGetRegion(%d) but image not bound", id)
|
||||
return ""
|
||||
|
||||
return image_to_stream(image.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")
|
||||
|
||||
def bind_find_subimage(
|
||||
self, id: int, subimg: str, img_width: int, x: int, y: int, w: int, h: int
|
||||
):
|
||||
if id == 0:
|
||||
return ""
|
||||
|
||||
assert x >= 0
|
||||
assert y >= 0
|
||||
|
||||
try:
|
||||
image = self._bound_regions[id - 1]
|
||||
except IndexError:
|
||||
self.logger.warning("bindGetRegion(%d) but image not bound", id)
|
||||
self.logger.warning("bindGetRegionRaw(%d) but image not bound", id)
|
||||
return ""
|
||||
|
||||
image = ensure_np_image(image.image)
|
||||
image = np_crop(image, x, y, w, h)
|
||||
subimg = decode_image(subimg, img_width)
|
||||
|
||||
start = time.monotonic()
|
||||
out = find_subimages(image, subimg)
|
||||
self.logger.debug("OCR took %fs", time.monotonic() - start)
|
||||
out = [{"x": int(i[0]), "y": int(i[1])} for i in out]
|
||||
return json.dumps(out)
|
||||
return image_to_stream(image.image, x, y, w, h, mode="rgba", ignore_limit=True)
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sys
|
||||
import collections
|
||||
import functools
|
||||
import hashlib
|
||||
@@ -225,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)
|
||||
|
||||
+53
-10
@@ -1,10 +1,11 @@
|
||||
import base64
|
||||
from typing import Union
|
||||
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
|
||||
@@ -20,29 +21,71 @@ class ApiPermissionDeniedException(Exception):
|
||||
self.required_permission = required_permission
|
||||
|
||||
|
||||
ImgTypeG = TypeVar("T", np.ndarray, Image.Image)
|
||||
|
||||
|
||||
def ensure_image_rgba(image: ImgTypeG) -> ImgTypeG:
|
||||
# XXX: This function is not idempotent!
|
||||
if isinstance(image, np.ndarray):
|
||||
return image[:, :, [2, 1, 0, 3]]
|
||||
else:
|
||||
if image.mode == "RGB":
|
||||
image = image.convert("RGBA")
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def ensure_image_bgra(image: ImgTypeG) -> ImgTypeG:
|
||||
# XXX: This function is not idempotent!
|
||||
if isinstance(image, np.ndarray):
|
||||
return image
|
||||
else:
|
||||
if image.mode == "RGB":
|
||||
image = image.convert("RGBA")
|
||||
|
||||
r, g, b, a = image.split()
|
||||
image = Image.merge("RGBA", (b, g, r, a))
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def ensure_image(image: ImgTypeG, mode: str) -> ImgTypeG:
|
||||
if mode == "rgba":
|
||||
return ensure_image_rgba(image)
|
||||
elif mode == "bgra":
|
||||
return ensure_image_bgra(image)
|
||||
else:
|
||||
raise ValueError("invalid mode")
|
||||
|
||||
|
||||
def image_to_stream(
|
||||
image: Union[Image.Image, np.ndarray], x=0, y=0, width=None, height=None
|
||||
image: ImageType,
|
||||
x=0,
|
||||
y=0,
|
||||
width=None,
|
||||
height=None,
|
||||
mode="bgra",
|
||||
ignore_limit=False,
|
||||
) -> bytes:
|
||||
if isinstance(image, np.ndarray):
|
||||
return base64.b64encode(np_crop(image, x, y, width, height).tobytes())
|
||||
out = np_crop(image, x, y, width, height)
|
||||
out = ensure_image(out, mode).tobytes()
|
||||
else:
|
||||
assert image.mode == "RGB" or image.mode == "RGBA"
|
||||
assert image.mode == "RGBA"
|
||||
|
||||
if width is None:
|
||||
width = image.width
|
||||
if height is None:
|
||||
height = image.height
|
||||
|
||||
if width * height * 4 > TRANSFER_LIMIT:
|
||||
if not ignore_limit and width * height * 4 > TRANSFER_LIMIT:
|
||||
return ""
|
||||
|
||||
if image.mode == "RGB":
|
||||
image = image.convert("RGBA")
|
||||
image = image.crop((x, y, x + width, y + height))
|
||||
|
||||
r, g, b, a = image.crop((x, y, x + width, y + height)).split()
|
||||
image = Image.merge("RGBA", (b, g, r, a))
|
||||
out = ensure_image(image, mode).tobytes()
|
||||
|
||||
return base64.b64encode(image.tobytes())
|
||||
return out
|
||||
|
||||
|
||||
def encode_mouse(x: int, y: int) -> int:
|
||||
|
||||
@@ -8,7 +8,7 @@ from PySide2.QtCore import QObject, Signal, Property, QRect, Slot
|
||||
from PySide2.QtGui import QWindow
|
||||
from PySide2.QtWidgets import QGraphicsItem
|
||||
|
||||
from ..image.np_utils import np_crop
|
||||
from runekit.image.np_utils import np_crop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import GameManager
|
||||
@@ -68,7 +68,7 @@ class GameInstance(QObject):
|
||||
world = Property(int, get_world, notify=worldChanged)
|
||||
|
||||
@abc.abstractmethod
|
||||
def grab_game(self) -> Union[Image.Image, np.ndarray]:
|
||||
def grab_game(self) -> ImageType:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
@@ -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.
+1
-1
@@ -12,7 +12,7 @@ _debug_dump_file = False
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def qpixmap_to_np(im: QPixmap):
|
||||
def qpixmap_to_np(im: QPixmap) -> np.ndarray:
|
||||
# from PIL.ImageQt.fromqimage
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.ReadWrite)
|
||||
|
||||
@@ -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,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)
|
||||
+132
-29
@@ -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,
|
||||
@@ -15,10 +17,14 @@ from PySide2.QtWidgets import (
|
||||
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
|
||||
@@ -27,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")
|
||||
@@ -41,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):
|
||||
@@ -50,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):
|
||||
@@ -69,43 +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)
|
||||
|
||||
# I think this doesn't belong to "Interface"
|
||||
# either rename it to settings or make separate game tab
|
||||
refresh_label = QLabel("Refresh interval", self)
|
||||
refresh_layout = QHBoxLayout(self)
|
||||
refresh_field = QSpinBox(self)
|
||||
refresh_field.setRange(100, 2000)
|
||||
refresh_field.setSingleStep(100)
|
||||
refresh_field.setValue(100)
|
||||
refresh_layout.addWidget(refresh_field, 1)
|
||||
refresh_unit = QLabel("ms", self)
|
||||
refresh_layout.addWidget(refresh_unit, 0)
|
||||
layout.addRow(refresh_label, refresh_layout)
|
||||
|
||||
@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
|
||||
@@ -116,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)
|
||||
@@ -0,0 +1 @@
|
||||
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,59 +0,0 @@
|
||||
from typing import Tuple, List
|
||||
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
_debug_save_images = False
|
||||
|
||||
|
||||
def find_subimages(image: np.ndarray, subimage: np.ndarray) -> List[Tuple[int, int]]:
|
||||
"""
|
||||
Detect BGRA subimage in BGRA image with the following limitations
|
||||
|
||||
* Image transparency is not supported (alpha channel is removed)
|
||||
* Subimage semi-translucency is not supported
|
||||
"""
|
||||
mask = subimage[:, :, 3]
|
||||
image_bgr = image[:, :, :3]
|
||||
subimage_bgr = subimage[:, :, :3]
|
||||
matched = cv2.matchTemplate(image_bgr, subimage_bgr, cv2.TM_SQDIFF, mask=mask).get()
|
||||
|
||||
# allow up to 30 pixel per pixel
|
||||
threshold = (30 ** 2) * subimage.shape[0] * subimage.shape[1]
|
||||
loc = np.where((matched < threshold) & (matched != np.inf))
|
||||
out = []
|
||||
for pos in zip(*loc[::-1]):
|
||||
out.append(pos)
|
||||
|
||||
if _debug_save_images:
|
||||
from runekit.image.np_utils import np_save_image
|
||||
import hashlib
|
||||
|
||||
cv2.normalize(matched, matched, 0, 0xFF, cv2.NORM_MINMAX)
|
||||
matched = matched.astype("uint8")
|
||||
image_bgr = image_bgr.copy()
|
||||
|
||||
subimg_hash = hashlib.blake2b(subimage.data, digest_size=8).hexdigest()
|
||||
|
||||
for pos in out:
|
||||
cv2.rectangle(
|
||||
matched,
|
||||
pos,
|
||||
(pos[0] + subimage.shape[1], pos[1] + subimage.shape[0]),
|
||||
0,
|
||||
2,
|
||||
)
|
||||
cv2.rectangle(
|
||||
image_bgr,
|
||||
pos,
|
||||
(pos[0] + subimage.shape[1], pos[1] + subimage.shape[0]),
|
||||
(0, 255, 0),
|
||||
2,
|
||||
)
|
||||
|
||||
np_save_image(image_bgr, f"/tmp/debug-image-{subimg_hash}.png")
|
||||
np_save_image(subimage_bgr, f"/tmp/debug-subimage-{subimg_hash}.png")
|
||||
np_save_image(mask, f"/tmp/debug-mask-{subimg_hash}.png")
|
||||
np_save_image(matched, f"/tmp/debug-result-{subimg_hash}.png")
|
||||
|
||||
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
|
||||
+37
-7
@@ -1,6 +1,7 @@
|
||||
import abc
|
||||
from typing import Union
|
||||
from typing import Union, Dict
|
||||
|
||||
from PySide2.QtCore import QObject, QSettings
|
||||
from PySide2.QtWidgets import QSystemTrayIcon
|
||||
|
||||
from .tooltip import TooltipManager
|
||||
@@ -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()
|
||||
@@ -27,17 +33,41 @@ class TrayIconNotifier(Notifier):
|
||||
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.
+56
-2
@@ -1,13 +1,67 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide2.QtCore import Slot, Signal, QCoreApplication
|
||||
from PySide2.QtGui import QIcon
|
||||
from PySide2.QtWidgets import QSystemTrayIcon
|
||||
from PySide2.QtWidgets import QSystemTrayIcon, QMenu
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from runekit.host import Host
|
||||
|
||||
|
||||
class TrayIcon(QSystemTrayIcon):
|
||||
def __init__(self):
|
||||
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