mirror of
https://github.com/bdnugget/runekit.git
synced 2026-11-07 09:07:30 +00:00
Launcher
This commit is contained in:
1 parent
f9a9e0bdc0
commit
5bb38fea64
11 files changed
+299
-63
No files matched your search
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Whitespace-only changes.
+35
-2
@@ -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
|
||||
Reference in new issue
Block a user