Files
DemonEditor/app/ui/extensions/management.py
2024-07-27 23:41:07 +03:00

369 lines
16 KiB
Python

# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2023-2024 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
import json
import os
import shutil
from enum import IntEnum
from pathlib import Path
import requests
from gi.repository import Gtk, Gdk, GLib, Pango, GObject
from app.commons import log, run_task, run_idle
from app.ui.dialogs import translate
from app.ui.uicommons import HeaderBar
EXT_URL = "https://api.github.com/repos/DYefremov/demoneditor-extensions/contents/extensions/"
EXT_LIST_FILE = "https://raw.githubusercontent.com/DYefremov/demoneditor-extensions/main/extensions/extension-list"
# Config file name. The config file must be in json format!
# E.g. -> {"EXT_URL": "repo URL", "EXT_LIST_FILE": "URL to 'extension-list' file."}
EXT_CONFIG_FILE = "ext_sources"
HEADERS = {"User-Agent": "Mozilla/5.0 (X11; Linux i686; rv:112.0) Gecko/20100101 Firefox/112.0",
"Accept": "application/json"}
class ExtensionManager(Gtk.Window):
ICON_INFO = "emblem-important-symbolic"
ICON_UPDATE = "network-receive-symbolic"
class Column(IntEnum):
TITLE = 0
DESC = 1
VER = 2
INFO = 3
STATUS = 4
NAME = 5
URL = 6
PATH = 7
def __init__(self, app, **kwargs):
super().__init__(title=translate("Extensions"), icon_name="demon-editor", application=app,
transient_for=app.app_window, destroy_with_parent=True,
window_position=Gtk.WindowPosition.CENTER_ON_PARENT,
default_width=560, default_height=320, modal=True, **kwargs)
self._app = app
self._ext_path = f"{self._app.app_settings.default_data_path}tools{os.sep}extensions"
margin = {"margin_start": 5, "margin_end": 5, "margin_top": 5, "margin_bottom": 5}
base_margin = {"margin_start": 10, "margin_end": 10, "margin_top": 10, "margin_bottom": 10}
# Title, Description, Version, Info, Status, Name, URL, Path.
self._model = Gtk.ListStore.new((str, str, str, str, bool, str, str, object))
self._model.connect("row-deleted", self.on_model_changed)
self._model.connect("row-inserted", self.on_model_changed)
self._view = Gtk.TreeView(activate_on_single_click=True, enable_grid_lines=Gtk.TreeViewGridLines.BOTH)
self._view.set_model(self._model)
self._view.set_tooltip_column(self.Column.DESC)
self._view.connect("row-activated", self.on_row_activated)
# Title
renderer = Gtk.CellRendererText(xalign=0.05, ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn(title=translate("Title"), cell_renderer=renderer, text=self.Column.TITLE)
column.set_alignment(0.5)
column.set_min_width(170)
column.set_resizable(True)
self._view.append_column(column)
# Description
renderer = Gtk.CellRendererText(xalign=0.05, ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn(title=translate("Description"), cell_renderer=renderer, text=self.Column.DESC)
column.set_alignment(0.5)
column.set_resizable(True)
column.set_expand(True)
self._view.append_column(column)
# Version
column = Gtk.TreeViewColumn(translate("Ver."))
column.set_alignment(0.5)
column.set_fixed_width(70)
renderer = Gtk.CellRendererText(xalign=0.5)
column.pack_start(renderer, True)
column.add_attribute(renderer, "text", self.Column.VER)
renderer = Gtk.CellRendererPixbuf()
column.pack_start(renderer, True)
column.add_attribute(renderer, "icon_name", self.Column.INFO)
self._view.append_column(column)
# Status
renderer = Gtk.CellRendererToggle(xalign=0.5)
column = Gtk.TreeViewColumn(title=translate("Installed"), cell_renderer=renderer, active=self.Column.STATUS)
column.set_alignment(0.5)
column.set_fixed_width(100)
self._view.append_column(column)
self._status_column = column
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
frame = Gtk.Frame(shadow_type=Gtk.ShadowType.IN, **base_margin)
frame.get_style_context().add_class("view")
data_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.VERTICAL, **base_margin)
data_box.set_margin_bottom(margin.get("margin_bottom", 5))
# Status bar.
status_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.HORIZONTAL, margin_start=5, margin_end=5)
count_icon = Gtk.Image.new_from_icon_name("document-properties", Gtk.IconSize.SMALL_TOOLBAR)
status_box.pack_start(count_icon, False, False, 0)
self._count_label = Gtk.Label(label="0", width_chars=4, xalign=0)
status_box.pack_start(self._count_label, False, False, 0)
status_box.show_all()
load_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.HORIZONTAL, margin_end=10, no_show_all=True)
load_box.pack_start(Gtk.Label(label=translate("Loading data..."), visible=True), False, False, 0)
self._load_spinner = Gtk.Spinner(visible=True)
self._load_spinner.bind_property("active", load_box, "visible")
self._load_spinner.bind_property("active", self._view, "sensitive", GObject.BindingFlags.INVERT_BOOLEAN)
load_box.pack_end(self._load_spinner, False, False, 0)
status_box.pack_end(load_box, False, False, 0)
data_box.pack_end(status_box, False, True, 0)
scrolled = Gtk.ScrolledWindow(shadow_type=Gtk.ShadowType.IN)
scrolled.add(self._view)
data_box.pack_start(scrolled, True, True, 0)
data_box.set_margin_start(10)
frame.add(data_box)
self.add(main_box)
# Popup menu.
menu = Gtk.Menu()
download_menu_item = Gtk.MenuItem.new_with_label(translate("Download"))
download_menu_item.connect("activate", self.on_download)
menu.append(download_menu_item)
remove_menu_item = Gtk.MenuItem.new_with_label(translate("Remove"))
remove_menu_item.connect("activate", self.on_remove)
menu.append(remove_menu_item)
menu.show_all()
self._view.connect("button-press-event", self.on_view_popup_menu, menu)
# Header and toolbar.
self._download_button = Gtk.Button.new_from_icon_name("go-bottom-symbolic", Gtk.IconSize.BUTTON)
self._download_button.set_tooltip_text(translate("Download"))
self._download_button.set_always_show_image(True)
self._download_button.connect("clicked", self.on_download)
remove_button = Gtk.Button.new_from_icon_name("user-trash-symbolic", Gtk.IconSize.BUTTON)
remove_button.set_tooltip_text(translate("Remove"))
remove_button.set_always_show_image(True)
remove_button.connect("clicked", self.on_remove)
if app.app_settings.use_header_bar:
header = HeaderBar()
header.pack_start(self._download_button)
header.pack_start(remove_button)
self.set_titlebar(header)
header.show_all()
else:
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
toolbar.get_style_context().add_class("primary-toolbar")
margin["margin_start"] = 15
margin["margin_top"] = 10
button_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.HORIZONTAL, **margin)
button_box.pack_start(self._download_button, False, False, 0)
button_box.pack_start(remove_button, False, False, 0)
toolbar.pack_start(button_box, True, True, 0)
main_box.pack_start(toolbar, False, False, 0)
main_box.pack_start(frame, True, True, 0)
main_box.show_all()
# Connection status.
self._connection_status_image = Gtk.Image.new_from_icon_name("network-offline-symbolic", Gtk.IconSize.BUTTON)
status_box.pack_end(self._connection_status_image, False, False, 0)
self._download_button.bind_property("visible", self._connection_status_image, "visible", 4)
self._download_button.bind_property("visible", download_menu_item, "visible")
ws_property = "extension_manager_window_size"
window_size = self._app.app_settings.get(ws_property, None)
if window_size:
self.resize(*window_size)
self.connect("delete-event", lambda w, e: self._app.app_settings.add(ws_property, w.get_size()))
self.connect("realize", self.init)
def init(self, widget):
self._load_spinner.start()
scf = f"{os.path.dirname(__file__)}{os.sep}{EXT_CONFIG_FILE}"
if os.path.isfile(scf):
with (open(scf, "r", encoding="utf-8", errors="ignore") as cf):
config = json.load(cf)
global EXT_URL, EXT_LIST_FILE
EXT_URL = config.get("EXT_URL", EXT_URL)
EXT_LIST_FILE = config.get("EXT_LIST_FILE", EXT_LIST_FILE)
self.update()
def get_installed(self):
import pkgutil
from importlib.util import module_from_spec
ext_paths = [f"{os.path.dirname(__file__)}{os.sep}", self._ext_path, "extensions"]
installed = {}
for importer, name, is_package in pkgutil.iter_modules(ext_paths):
if is_package:
spec = importer.find_spec(name)
if spec is None:
log(f"{self.__class__.__name__} [get installed]: Module {name} not found.")
continue
m = module_from_spec(spec)
spec.loader.exec_module(m)
cls_name = name.capitalize()
if hasattr(m, cls_name):
cls = getattr(m, cls_name)
path = Path(spec.origin).parent
installed[name] = (cls, path)
return installed
@run_task
def update(self):
error_msg = None
try:
with requests.get(url=EXT_LIST_FILE, stream=True) as resp:
if resp.status_code == 200:
try:
self.update_data(resp.json())
except ValueError as e:
error_msg = f"{self.__class__.__name__} [update] error: {e}"
else:
error_msg = f"{self.__class__.__name__} [update] error: {resp.reason}"
GLib.idle_add(self._app.show_error_message, "Data loading error!")
except OSError as e:
error_msg = f"{self.__class__.__name__} [update] error: Connection error. {e}"
if error_msg:
log(error_msg)
self.update_local_data()
@run_idle
def update_data(self, data):
self._model.clear()
gen = self.append_data(data)
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
@run_idle
def update_local_data(self):
self._download_button.set_visible(False)
self._load_spinner.stop()
self._model.clear()
for ext, d in self.get_installed().items():
e, path = d
self._model.append((e.LABEL, None, e.VERSION, None, path, ext, None, path))
def append_data(self, data):
installed = self.get_installed()
for e, d in data.items():
url = f"{EXT_URL}{d.get('ref', '')}"
desc = d.get("description", "")
ver = d.get("version", "1.0")
info = self.ICON_UPDATE
path = None
ext = installed.get(e)
if ext:
info = None
ext_ver = ext[0].VERSION
path = ext[1]
if ext_ver < ver:
ver = ext_ver
info = self.ICON_INFO
yield self._model.append((d.get('label'), desc, ver, info, path, e, url, path))
self._load_spinner.stop()
def on_remove(self, item=None):
model, paths = self._view.get_selection().get_selected_rows()
if not paths:
return
itr = model.get_iter(paths)
path = model[itr][self.Column.PATH]
if path:
try:
shutil.rmtree(path)
except OSError as e:
log(f"{self.__class__.__name__} [remove] error: {e}")
else:
model.set(itr, {self.Column.INFO: self.ICON_UPDATE, self.Column.STATUS: None, self.Column.PATH: None})
msg = translate('Restart the program to apply all changes.')
self._app.show_info_message(msg, Gtk.MessageType.WARNING)
@run_task
def on_download(self, item=None):
model, paths = self._view.get_selection().get_selected_rows()
if not paths:
return
itr = model.get_iter(paths)
url = model[itr][self.Column.URL]
ver = model[itr][self.Column.VER]
if not url:
return
GLib.idle_add(self._load_spinner.start)
urls = {}
with requests.get(url=url, headers=HEADERS, stream=True) as resp:
if resp.status_code == 200:
try:
for f in resp.json():
url = f.get("download_url", None)
ver = f.get("version", ver)
if url:
urls[url] = f.get("name", None)
except ValueError as e:
log(f"{self.__class__.__name__} [download] error: {e}")
else:
log(f"{self.__class__.__name__} [download] error: {resp.reason}")
if urls:
path = f"{self._ext_path}{os.sep}{model[paths][self.Column.NAME]}{os.sep}"
os.makedirs(os.path.dirname(path), exist_ok=True)
if all((self.download_file(u, f"{path}{n}") for u, n in urls.items())):
data = {self.Column.VER: ver, self.Column.INFO: None, self.Column.STATUS: True, self.Column.PATH: path}
GLib.idle_add(model.set, itr, data)
msg = translate('Restart the program to apply all changes.')
self._app.show_info_message(msg, Gtk.MessageType.WARNING)
GLib.idle_add(self._load_spinner.stop)
def download_file(self, url, path):
with requests.get(url=url, headers=HEADERS, stream=True) as resp:
if resp.status_code == 200:
with open(path, mode="bw") as f:
for data in resp.iter_content(chunk_size=1024):
f.write(data)
return True
def on_model_changed(self, model, path, itr=None):
self._count_label.set_text(str(len(model)))
def on_row_activated(self, view, path, column):
if column is self._status_column:
self.on_remove() if view.get_model()[path][self.Column.STATUS] else self.on_download()
def on_view_popup_menu(self, view, event, menu):
if event.get_event_type() == Gdk.EventType.BUTTON_PRESS and event.button == Gdk.BUTTON_SECONDARY:
menu.popup(None, None, None, None, event.button, event.time)
return True
return False
if __name__ == "__main__":
pass