This commit is contained in:
Manatsawin Hanmongkolchai committed 2021-02-21 23:50:18 +07:00
1 parent f9a9e0bdc0
commit 5bb38fea64
11 files changed
+299 -63

No files matched your search

+9 -1
View File
@@ -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
+18 -40
View File
@@ -3,6 +3,7 @@ import sys
import traceback
import click
from PySide2.QtCore import QSettings, Qt
from PySide2.QtWidgets import (
QApplication,
QMessageBox,
@@ -15,7 +16,6 @@ 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(
@@ -31,51 +31,28 @@ def main(app_url, game_index, qt_args):
logging.info("Starting QtWebEngine")
browser.init()
app = QApplication(["runekit", *qt_args])
app.setQuitOnLastWindowClosed(False)
game_manager = None
try:
if QSystemTrayIcon.isSystemTrayAvailable():
TrayIcon().show()
app.setOrganizationName("cupco.de")
app.setOrganizationDomain("cupco.de")
app.setApplicationName("RuneKit")
host = Host()
QSettings.setDefaultFormat(QSettings.IniFormat)
try:
game_manager = get_platform_manager()
host = Host(game_manager)
if not host.app_store.has_default_apps():
host.app_store.load_default_apps()
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()
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.start_app(app_url, game)
game_app = host.launch_app_from_url(app_url)
game_app.window.destroyed.connect(app.quit)
app.exec_()
@@ -92,6 +69,7 @@ def main(app_url, game_index, qt_args):
raise
finally:
if game_manager is not None:
logging.debug("Stopping game manager")
game_manager.stop()
+1 -1
View File
@@ -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
View File
@@ -1 +1,2 @@
from .app import App
from .store import AppStore
+3
View File
@@ -51,6 +51,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 +59,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 +67,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"])
+147
View File
@@ -0,0 +1,147 @@
import json
import logging
import hashlib
from typing import Iterator, Tuple
from urllib.parse import urljoin
from PySide2.QtCore import (
QObject,
QSettings,
QThread,
Signal,
Slot,
)
from PySide2.QtWidgets import QProgressDialog
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)
canceled = False
def __init__(self, parent: "AppStore"):
super().__init__(parent=parent)
self.appstore = parent
def run(self):
try:
default_apps = fetch_bom_json(REGISTRY_URL)
self.items.emit(len(default_apps) + 1)
self.progress.emit(1)
for index, app in enumerate(default_apps):
self.label.emit(f"Installing {app['name']}")
self.progress.emit(index + 2)
url = urljoin(REGISTRY_URL, app["url"])
self.add_app(url, app["folder"])
if self.canceled:
return
finally:
self.items.emit(100)
self.progress.emit(100)
def add_app(self, url, folder=""):
manifest = fetch_bom_json(url)
try:
self.appstore.add_app(url, manifest)
if folder != "":
self.appstore.add_app_to_folder(url, folder)
except AddAppError:
logger.warning("Unable to add application %s", url, exc_info=True)
@Slot()
def cancel(self):
self.canceled = True
class AppStore(QObject):
app_change = Signal()
def __init__(self):
super().__init__()
self.settings = QSettings(self)
def has_default_apps(self) -> bool:
return self.settings.value("apps/_meta/isDefaultLoaded")
def load_default_apps(self):
progress = QProgressDialog("Loading default apps", "Cancel", 0, 100)
thread = _FetchRegistryThread(self)
thread.progress.connect(progress.setValue)
thread.items.connect(progress.setMaximum)
thread.label.connect(progress.setLabelText)
progress.canceled.connect(thread.cancel)
thread.start()
progress.exec_()
self.settings.setValue("apps/_meta/isDefaultLoaded", True)
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"])
manifest["configUrl"] = urljoin(manifest_url, manifest["configUrl"])
except KeyError:
raise AddAppError(manifest_url)
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 add_app_to_folder(self, manifest_url: str, folder: str):
assert "/" not in folder
appid = app_id(manifest_url)
settings = QSettings()
settings.beginGroup(f"apps/_folder/{folder}")
last_id = 0
for key in self.settings.childKeys():
last_id = max(int(key), last_id)
if appid == self.settings.value(key):
self.settings.endGroup()
return
keys = [int(x) for x in self.settings.childKeys()]
last_id = max(keys) if keys else 0
settings.endGroup()
settings.setValue(f"apps/_folder/{folder}/{last_id + 1}", appid)
self.app_change.emit()
def __iter__(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 __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,7 +1,7 @@
from PySide2.QtCore import QAbstractItemModel, Qt, QModelIndex
class ApplicationModel(QAbstractItemModel):
class AppStoreModel(QAbstractItemModel):
h_columns = ("Name", "URL", "Description")
def columnCount(self, parent: QModelIndex) -> int:
+82 -15
View File
@@ -1,34 +1,101 @@
from typing import TYPE_CHECKING
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.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.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)
def on_before_quit(self):
for app in self.open_app:
# FIXME: Why app don't cleanup after themselves?
if app.window:
app.window.deleteLater()
app.window = None
if app.alt1api:
app.alt1api.deleteLater()
app.alt1api = None
if app.web_profile:
app.web_profile.deleteLater()
app.web_profile = None
self.open_app = []
def launch_app_from_url(self, manifest_url) -> App:
manifest = fetch_bom_json(manifest_url)
return self.launch_app(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(manifest)
def launch_app(self, manifest: AppManifest):
instances = self.manager.get_instances()
if len(instances) == 0:
QMessageBox.critical(
None,
"No game instances found",
"Cannot find open RuneScape window. Please launch the game before any apps",
)
return
app = App(
host=self,
manifest=manifest,
game_instance=instance,
source_url=manifest_url,
game_instance=instances[0],
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()
+2 -3
View File
@@ -17,7 +17,7 @@ from PySide2.QtWidgets import (
QSpinBox,
)
from .applications import ApplicationModel
from .appstore_model import AppStoreModel
from ..ui import TooltipNotifier, TrayIconNotifier
if TYPE_CHECKING:
@@ -27,7 +27,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")
@@ -57,7 +56,7 @@ class ApplicationPage(QWidget):
self.setLayout(layout)
tree = QTreeView(self)
model = ApplicationModel(self)
model = AppStoreModel(self)
tree.setModel(model)
tree.setUniformRowHeights(True)
layout.addWidget(tree, 1)
View File
Whitespace-only changes.
+35 -2
View File
@@ -1,13 +1,46 @@
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()
def _setup_menu(self):
menu = QMenu("RuneKit")
for app_id, manifest in self.host.app_store:
app_menu = menu.addAction(manifest["appName"])
app_menu.triggered.connect(
lambda _=None, app_id=app_id: self.host.launch_app_id(app_id)
)
menu.addSeparator()
settings = menu.addAction("Settings")
settings.triggered.connect(self.on_settings)
menu.addAction("Exit", lambda: QCoreApplication.instance().quit())
self.setContextMenu(menu)
@Slot()
def update_menu(self):
self._setup_menu()
def tray_icon() -> TrayIcon:
return _tray_icon