├── po ├── LINGUAS ├── meson.build └── POTFILES ├── src ├── __init__.py ├── models │ ├── __init__.py │ ├── AppsListSection.py │ ├── Models.py │ ├── AppListElement.py │ └── Provider.py ├── providers │ ├── __init__.py │ ├── providers_list.py │ ├── AppImageProvider.py │ └── FlatpakProvider.py ├── lib │ ├── __init__.py │ ├── async_utils.py │ ├── terminal.py │ ├── utils.py │ └── flatpak.py ├── assets │ ├── App-image-logo.png │ ├── appimage-showcase.png │ ├── Flatpak-Logo-showcase.png │ ├── style.css │ ├── App-image-logo-bw.svg │ └── flathub-badge-logo.svg ├── AboutDialog.py ├── boutique.gresource.xml ├── components │ ├── FilterEntry.py │ ├── CustomComponents.py │ └── AppListBoxItem.py ├── State.py ├── meson.build ├── gtk │ ├── main-menu.xml │ └── help-overlay.ui ├── boutique.in ├── window.ui ├── main.py ├── BrowseApps.py ├── UpdatesList.py ├── BoutiqueWindow.py ├── InstalledAppsList.py └── AppDetails.py ├── .github └── FUNDING.yml ├── requirements.txt ├── data ├── gschemas.compiled ├── it.mijorus.boutique.desktop.in ├── icons │ ├── hicolor │ │ ├── scalable │ │ │ ├── actions │ │ │ │ ├── window-pop-out-symbolic.svg │ │ │ │ ├── software-update-available-symbolic.svg │ │ │ │ ├── application-x-executable-symbolic.svg │ │ │ │ ├── funnel-outline-symbolic.svg │ │ │ │ ├── computer-symbolic.svg │ │ │ │ └── globe-symbolic.svg │ │ │ └── apps │ │ │ │ ├── it.mijorus.boutique.svg │ │ │ │ └── it.mijorus.boutique.Devel.svg │ │ └── symbolic │ │ │ └── apps │ │ │ └── it.mijorus.boutique-symbolic.svg │ └── meson.build ├── it.mijorus.boutique.gschema.xml ├── it.mijorus.boutique.appdata.xml.in └── meson.build ├── .gitignore ├── boutique.code-workspace ├── meson.build ├── .vscode └── settings.json ├── README.md ├── locales └── en │ └── translation.missing.json ├── it.mijorus.boutique.Devel.json └── python3-requirements.json /po/LINGUAS: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: mijorus 2 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext('boutique', preset: 'glib') 2 | -------------------------------------------------------------------------------- /src/providers/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | 'FlatpakProvider', 3 | ] -------------------------------------------------------------------------------- /src/lib/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | 'flatpak', 3 | 'terminal' 4 | ] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests >= 2.25.1 2 | html2text == 2020.1.16 3 | pyxdg == 0.28 -------------------------------------------------------------------------------- /data/gschemas.compiled: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mijorus/boutique/HEAD/data/gschemas.compiled -------------------------------------------------------------------------------- /src/assets/App-image-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mijorus/boutique/HEAD/src/assets/App-image-logo.png -------------------------------------------------------------------------------- /src/assets/appimage-showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mijorus/boutique/HEAD/src/assets/appimage-showcase.png -------------------------------------------------------------------------------- /src/assets/Flatpak-Logo-showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mijorus/boutique/HEAD/src/assets/Flatpak-Logo-showcase.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .flatpak-builder 3 | build 4 | builddir 5 | run.sh 6 | test.py 7 | install.sh 8 | .flatpak/* 9 | 10 | build-aux/patches -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | data/it.mijorus.boutique.desktop.in 2 | data/it.mijorus.boutique.appdata.xml.in 3 | data/it.mijorus.boutique.gschema.xml 4 | src/window.ui 5 | src/main.py 6 | src/window.py 7 | 8 | -------------------------------------------------------------------------------- /src/models/AppsListSection.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | class AppsListSection(): 4 | def __init__(self, name: str, apps_list: List): 5 | self.name = name 6 | self.apps_list = apps_list 7 | -------------------------------------------------------------------------------- /data/it.mijorus.boutique.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Boutique 3 | Exec=boutique %U 4 | Icon=it.mijorus.boutique 5 | Terminal=false 6 | Type=Application 7 | Categories=GTK; 8 | StartupNotify=true 9 | MimeType=application/vnd.flatpak.ref;application/vnd.appimage; -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/actions/window-pop-out-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /data/it.mijorus.boutique.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | "~/AppImages" 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /data/it.mijorus.boutique.appdata.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | it.mijorus.boutique.desktop 4 | CC0-1.0 5 | GPL-3.0-or-later 6 | 7 |

No description

8 |
9 |
10 | -------------------------------------------------------------------------------- /src/providers/providers_list.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from ..models.Provider import Provider 3 | from .FlatpakProvider import FlatpakProvider 4 | from .AppImageProvider import AppImageProvider 5 | 6 | # A list containing all the "Providers" currently only Flatpak is supported 7 | # but I might need to add other ones in the future 8 | providers: Dict[str, Provider] = { 9 | 'flatpak': FlatpakProvider(), 10 | 'appimage': AppImageProvider() 11 | } -------------------------------------------------------------------------------- /src/assets/style.css: -------------------------------------------------------------------------------- 1 | .border-bottom-fix :last-child { 2 | border-bottom: 0px; 3 | } 4 | 5 | .app-listbox-item { 6 | margin-top: 5px; 7 | margin-bottom: 5px; 8 | margin-left: 5px; 9 | margin-right: 5px; 10 | } 11 | 12 | .provider-icon { 13 | margin-top: 10px; 14 | margin-bottom: 10px; 15 | padding-top: 0px; 16 | padding-bottom: 0px; 17 | padding-left: 12px; 18 | padding-right: 12px; 19 | border-radius: 40px; 20 | } -------------------------------------------------------------------------------- /boutique.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "terminal.integrated.defaultProfile.linux": "bash", 9 | "files.watcherExclude": { 10 | "**/.git/objects/**": true, 11 | "**/.git/subtree-cache/**": true, 12 | "**/node_modules/*/**": true, 13 | "**/.hg/store/**": true, 14 | ".flatpak/**": true, 15 | "_build/**": true 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/AboutDialog.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | class AboutDialog(Gtk.AboutDialog): 4 | def __init__(self, parent): 5 | Gtk.AboutDialog.__init__(self) 6 | self.props.program_name = 'boutique' 7 | self.props.version = "0.1.0" 8 | self.props.authors = ['Lorenzo Paderi'] 9 | self.props.copyright = '2022 Lorenzo Paderi' 10 | self.props.logo_icon_name = 'it.mijorus.boutique' 11 | self.props.modal = True 12 | self.set_transient_for(parent) -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('boutique', 2 | version: '0.1.0', 3 | meson_version: '>= 0.59.0', 4 | default_options: [ 'warning_level=2', 5 | ], 6 | ) 7 | 8 | i18n = import('i18n') 9 | 10 | gnome = import('gnome') 11 | 12 | 13 | 14 | subdir('data') 15 | subdir('src') 16 | subdir('po') 17 | 18 | gnome.post_install( 19 | glib_compile_schemas: true, 20 | gtk_update_icon_cache: true, 21 | update_desktop_database: true, 22 | ) 23 | 24 | meson.add_install_script('build-aux/meson/postinstall.py') -------------------------------------------------------------------------------- /src/boutique.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | window.ui 5 | gtk/main-menu.xml 6 | assets/flathub-badge-logo.svg 7 | assets/App-image-logo-bw.svg 8 | assets/App-image-logo.png 9 | assets/Flatpak-Logo-showcase.png 10 | assets/appimage-showcase.png 11 | assets/style.css 12 | 13 | -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/actions/software-update-available-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/FilterEntry.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk, Pango, GObject, Gio, GdkPixbuf, GLib, Adw 2 | from typing import Dict, List 3 | 4 | 5 | class FilterEntry(Gtk.SearchEntry): 6 | __gsignals__ = { 7 | "selected-app": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, (object, )), 8 | } 9 | 10 | def __init__(self, label, capture: Gtk.Widget = None, **kwargs): 11 | super().__init__(**kwargs) 12 | 13 | self.props.placeholder_text = label 14 | self.set_key_capture_widget(capture) 15 | self.get_first_child().set_from_icon_name("funnel-outline-symbolic") 16 | -------------------------------------------------------------------------------- /src/State.py: -------------------------------------------------------------------------------- 1 | from gi.repository import GObject 2 | from typing import Callable 3 | 4 | 5 | class State(): 6 | def __init__(self): 7 | self.props = {} 8 | self.propscb = {} 9 | 10 | def set__(self, key, value): 11 | self.props[key] = value 12 | 13 | if key in self.propscb: 14 | for cb in self.propscb[key]: 15 | cb(value) 16 | 17 | def connect__(self, key: str, cb: Callable): 18 | if not key in self.propscb: 19 | self.propscb[key] = [] 20 | 21 | self.propscb[key].append(cb) 22 | 23 | 24 | state = State() 25 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": false, 3 | "python.linting.mypyEnabled": true, 4 | "python.linting.enabled": true, 5 | "VsCodeTaskButtons.tasks": { 6 | "label": "Label", 7 | "task": "prova", 8 | "tooltip": "Optional tooltip to show when hovering over the button (defaults to task name)", 9 | }, 10 | "terminal.integrated.defaultProfile.linux": "bash", 11 | "files.watcherExclude": { 12 | "**/.git/objects/**": true, 13 | "**/.git/subtree-cache/**": true, 14 | "**/node_modules/*/**": true, 15 | "**/.hg/store/**": true, 16 | ".flatpak/**": true, 17 | "_build/**": true 18 | } 19 | } -------------------------------------------------------------------------------- /src/lib/async_utils.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | import gi 4 | 5 | from gi.repository import GLib, GObject 6 | 7 | # Thank you to https://github.com/linuxmint/webapp-manager 8 | # check out common.py for the idea 9 | 10 | # Used as a decorator to run things in the background 11 | def _async(func): 12 | def wrapper(*args, **kwargs): 13 | thread = threading.Thread(target=func, args=args, kwargs=kwargs) 14 | thread.daemon = True 15 | thread.start() 16 | return thread 17 | return wrapper 18 | 19 | # Used as a decorator to run things in the main loop, from another thread 20 | def idle(func): 21 | def wrapper(*args, **kwargs): 22 | GLib.idle_add(func, *args) 23 | return wrapper -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/actions/application-x-executable-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 16 | 21 | 22 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 2 | moduledir = join_paths(pkgdatadir, 'boutique') 3 | gnome = import('gnome') 4 | 5 | gnome.compile_resources('boutique', 6 | 'boutique.gresource.xml', 7 | gresource_bundle: true, 8 | install: true, 9 | install_dir: pkgdatadir, 10 | ) 11 | 12 | 13 | 14 | python = import('python') 15 | 16 | conf = configuration_data() 17 | conf.set('PYTHON', python.find_installation('python3').path()) 18 | conf.set('VERSION', meson.project_version()) 19 | conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir'))) 20 | conf.set('pkgdatadir', pkgdatadir) 21 | 22 | configure_file( 23 | input: 'boutique.in', 24 | output: 'boutique', 25 | configuration: conf, 26 | install: true, 27 | install_dir: get_option('bindir') 28 | ) 29 | 30 | install_subdir('.', install_dir: moduledir) -------------------------------------------------------------------------------- /src/components/CustomComponents.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk, Adw, GObject, Gio, Gdk 2 | 3 | class CenteringBox(Gtk.Box): 4 | def __init__(self, **kwargs): 5 | super().__init__(valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER, **kwargs) 6 | 7 | class LabelStart(Gtk.Label): 8 | def __init__(self, **kwargs): 9 | super().__init__(halign=Gtk.Align.START, **kwargs) 10 | 11 | class LabelCenter(Gtk.Label): 12 | def __init__(self, **kwargs): 13 | super().__init__(halign=Gtk.Align.CENTER, **kwargs) 14 | 15 | class NoAppsFoundRow(Gtk.ListBoxRow): 16 | def __init__(self, **kwargs): 17 | super().__init__(hexpand=True) 18 | self.set_child( 19 | Gtk.Label( 20 | label="No apps found", 21 | css_classes=['app-listbox-item'], 22 | margin_bottom=20, 23 | margin_top=20 24 | ) 25 | ) -------------------------------------------------------------------------------- /src/gtk/main-menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | _Preferences 7 | app.preferences 8 | 9 | 10 | _Open File 11 | app.open_file 12 | 13 | 14 | _Open Log File 15 | app.open_log_file 16 | 17 | 18 | _About speedy 19 | app.about 20 | 21 |
22 |
23 |
-------------------------------------------------------------------------------- /data/icons/hicolor/scalable/actions/funnel-outline-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 12 | 15 | 18 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | application_id = 'it.mijorus.boutique' 2 | 3 | scalable_dir = join_paths('hicolor', 'scalable', 'apps') 4 | install_data( 5 | join_paths(scalable_dir, ('@0@.svg').format(application_id)), 6 | install_dir: join_paths(get_option('datadir'), 'icons', scalable_dir) 7 | ) 8 | 9 | symbolic_dir = join_paths('hicolor', 'symbolic', 'apps') 10 | install_data( 11 | join_paths(symbolic_dir, ('@0@-symbolic.svg').format(application_id)), 12 | install_dir: join_paths(get_option('datadir'), 'icons', symbolic_dir) 13 | ) 14 | 15 | action_dir = join_paths('hicolor', 'scalable', 'actions') 16 | action_icons = [ 17 | join_paths(action_dir, 'funnel-outline-symbolic.svg'), 18 | join_paths(action_dir, 'globe-symbolic.svg'), 19 | join_paths(action_dir, 'window-pop-out-symbolic.svg'), 20 | join_paths(action_dir, 'computer-symbolic.svg'), 21 | join_paths(action_dir, 'software-update-available-symbolic.svg'), 22 | join_paths(action_dir, 'application-x-executable-symbolic.svg'), 23 | ] 24 | 25 | install_data( 26 | action_icons, 27 | install_dir: join_paths(get_option('datadir'), 'icons', action_dir) 28 | ) -------------------------------------------------------------------------------- /src/models/Models.py: -------------------------------------------------------------------------------- 1 | 2 | from .AppListElement import AppListElement 3 | from typing import Optional 4 | 5 | class FlatpakHistoryElement(): 6 | def __init__(self, commit: str='', subject: str='', date: str=''): 7 | self.commit = commit 8 | self.subject = subject 9 | self.date = date 10 | 11 | class AppUpdateElement(): 12 | def __init__(self, app_id: str, size: Optional[str], to_verison: Optional[str], **kwargs): 13 | self.id: str = app_id 14 | self.size: Optional[str] = size 15 | self.to_version: Optional[str] = to_verison 16 | self.extra_data: dict = {} 17 | 18 | for k, v in kwargs.items(): 19 | self.extra_data[k] = v 20 | 21 | class SearchResultsItems(): 22 | def __init__(self, app_id: str, list_elements: list[AppListElement]): 23 | self.id: str = app_id 24 | self.list_elements: list[AppListElement] = list_elements 25 | 26 | class ProviderMessage(): 27 | def __init__(self, message: str, severity: str): 28 | """ severity can be: info, warn, danger """ 29 | self.message = message 30 | self.severity = severity -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Boutique 2 | 3 | This project is no longer in development 😢, sorry for that... 4 | 5 | ___ 6 | 7 |

8 | 9 |

10 | 11 |

12 | 13 |

14 | 15 |

16 | 17 |

18 | 19 | 20 | A work-in-progress Flatpak and Appimages app manager. 21 | 22 | Made with GTK4 23 | 24 | ## Credits 25 | 26 | Icon by daudix-ufo 27 | https://daudix-ufo.github.io/works/icons/ 28 | 29 | Thanks! 30 | 31 | ## Testing 32 | 33 | (please keep in mind this is alpha software) 34 | 35 | 1. Clone the repo 36 | 2. move into the cloned repo 37 | 3. run these commands: 38 | 39 | ```sh 40 | # builds the project 41 | flatpak-builder build/ it.mijorus.boutique.json --user --force-clean 42 | 43 | # runs the project 44 | flatpak-builder --run build/ it.mijorus.boutique.json boutique 45 | ``` 46 | -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/actions/computer-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 11 | 18 | 22 | 23 | 24 | 28 | 29 | -------------------------------------------------------------------------------- /src/models/AppListElement.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Optional, Dict, List 3 | 4 | from enum import Enum 5 | 6 | class InstalledStatus(Enum): 7 | INSTALLED = 1 8 | NOT_INSTALLED = 2 9 | UNINSTALLING = 3 10 | ERROR = 4 11 | UNKNOWN = 5 12 | INSTALLING = 6 13 | UPDATE_AVAILABLE = 7 14 | UPDATING = 8 15 | 16 | class AppListElement(): 17 | def __init__(self, name: str, description: str, app_id: str, provider: str, installed_status: InstalledStatus, size: float=None, alt_sources: Optional[other: AppListElement]=None, **kwargs): 18 | self.name: str = name 19 | self.description: str = description if description.strip() else 'No description provided' 20 | self.id = app_id 21 | self.provider: str = provider 22 | self.installed_status: InstalledStatus = installed_status 23 | self.size: Optional[float] = size 24 | self.alt_sources: Optional[other: AppListElement] = alt_sources 25 | 26 | self.extra_data: Dict[str, str] = {} 27 | for k, v in kwargs.items(): 28 | self.extra_data[k] = v 29 | 30 | def set_installed_status(self, installed_status: InstalledStatus): 31 | self.installed_status = installed_status 32 | -------------------------------------------------------------------------------- /src/gtk/help-overlay.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | True 5 | 6 | 7 | shortcuts 8 | 10 9 | 10 | 11 | General 12 | 13 | 14 | Show Shortcuts 15 | win.show-help-overlay 16 | 17 | 18 | 19 | 20 | Quit 21 | app.quit 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /locales/en/translation.missing.json: -------------------------------------------------------------------------------- 1 | { 2 | "&File": "&File", 3 | "Force &Reload": "Force &Reload", 4 | "&Quit": "&Quit", 5 | "&Edit": "&Edit", 6 | "Undo": "Undo", 7 | "Redo": "Redo", 8 | "Cut": "Cut", 9 | "Copy": "Copy", 10 | "Paste": "Paste", 11 | "Select All": "Select All", 12 | "Language": "Language", 13 | "en": "en", 14 | "es": "es", 15 | "hi": "hi", 16 | "it": "it", 17 | "pt": "pt", 18 | "Tab": "Tab", 19 | "Add New Tab": "Add New Tab", 20 | "Edit Active Tab": "Edit Active Tab", 21 | "Close Active Tab": "Close Active Tab", 22 | "Open Tab DevTools": "Open Tab DevTools", 23 | "Restore Tab": "Restore Tab", 24 | "Go to Next Tab": "Go to Next Tab", 25 | "Go to Previous Tab": "Go to Previous Tab", 26 | "Go to First Tab": "Go to First Tab", 27 | "Go to Last Tab": "Go to Last Tab", 28 | "&View": "&View", 29 | "Toggle Fullscreen": "Toggle Fullscreen", 30 | "Toggle Tab Bar": "Toggle Tab Bar", 31 | "Themes": "Themes", 32 | "Theme Manager": "Theme Manager", 33 | "&Settings": "&Settings", 34 | "&Help": "&Help", 35 | "&About": "&About", 36 | "Donate": "Donate", 37 | "Check For &Updates": "Check For &Updates", 38 | "Links": "Links", 39 | "Report Bugs/Issues": "Report Bugs/Issues", 40 | "Website": "Website", 41 | "Repository": "Repository", 42 | "Open &DevTools": "Open &DevTools" 43 | } -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | desktop_file = i18n.merge_file( 2 | input: 'it.mijorus.boutique.desktop.in', 3 | output: 'it.mijorus.boutique.desktop', 4 | type: 'desktop', 5 | po_dir: '../po', 6 | install: true, 7 | install_dir: join_paths(get_option('datadir'), 'applications') 8 | ) 9 | 10 | desktop_utils = find_program('desktop-file-validate', required: false) 11 | if desktop_utils.found() 12 | test('Validate desktop file', desktop_utils, 13 | args: [desktop_file] 14 | ) 15 | endif 16 | 17 | appstream_file = i18n.merge_file( 18 | input: 'it.mijorus.boutique.appdata.xml.in', 19 | output: 'it.mijorus.boutique.appdata.xml', 20 | po_dir: '../po', 21 | install: true, 22 | install_dir: join_paths(get_option('datadir'), 'appdata') 23 | ) 24 | 25 | appstream_util = find_program('appstream-util', required: false) 26 | if appstream_util.found() 27 | test('Validate appstream file', appstream_util, 28 | args: ['validate', appstream_file] 29 | ) 30 | endif 31 | 32 | install_data('it.mijorus.boutique.gschema.xml', 33 | install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas') 34 | ) 35 | 36 | compile_schemas = find_program('glib-compile-schemas', required: false) 37 | if compile_schemas.found() 38 | test('Validate schema file', compile_schemas, 39 | args: ['--strict', '--dry-run', meson.current_source_dir()] 40 | ) 41 | endif 42 | 43 | subdir('icons') 44 | -------------------------------------------------------------------------------- /it.mijorus.boutique.Devel.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id" : "it.mijorus.boutique", 3 | "runtime" : "org.gnome.Platform", 4 | "runtime-version" : "43", 5 | "sdk" : "org.gnome.Sdk", 6 | "command" : "boutique", 7 | "finish-args" : [ 8 | "--share=ipc", 9 | "--share=network", 10 | "--socket=wayland", 11 | "--socket=fallback-x11", 12 | "--device=dri", 13 | 14 | "--filesystem=xdg-data/applications:rw", 15 | "--filesystem=xdg-data/icons:ro", 16 | "--filesystem=xdg-data/flatpak:ro", 17 | "--filesystem=home/AppImages>rw", 18 | 19 | "--talk-name=org.freedesktop.FileManager1", 20 | "--talk-name=org.freedesktop.Flatpak", 21 | 22 | "--filesystem=/tmp:rw" 23 | ], 24 | "cleanup" : [ 25 | "/include", 26 | "/lib/pkgconfig", 27 | "/man", 28 | "/share/doc", 29 | "/share/gtk-doc", 30 | "/share/man", 31 | "/share/pkgconfig", 32 | "*.la", 33 | "*.a" 34 | ], 35 | "modules" : [ 36 | "./python3-requirements.json", 37 | { 38 | "name" : "boutique", 39 | "builddir" : true, 40 | "buildsystem" : "meson", 41 | "sources" : [ 42 | { 43 | "type" : "dir", 44 | "path" : "./" 45 | } 46 | ] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/boutique.in: -------------------------------------------------------------------------------- 1 | #!@PYTHON@ 2 | 3 | # boutique.in 4 | # 5 | # Copyright 2022 Lorenzo Paderi 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | 20 | import os 21 | import sys 22 | import signal 23 | import locale 24 | import gettext 25 | 26 | VERSION = '@VERSION@' 27 | pkgdatadir = '@pkgdatadir@' 28 | localedir = '@localedir@' 29 | 30 | sys.path.insert(1, pkgdatadir) 31 | signal.signal(signal.SIGINT, signal.SIG_DFL) 32 | locale.bindtextdomain('boutique', localedir) 33 | locale.textdomain('boutique') 34 | gettext.install('boutique', localedir) 35 | 36 | if __name__ == '__main__': 37 | import gi 38 | 39 | from gi.repository import Gio 40 | resource = Gio.Resource.load(os.path.join(pkgdatadir, 'boutique.gresource')) 41 | resource._register() 42 | 43 | from boutique import main 44 | sys.exit(main.main(VERSION)) 45 | -------------------------------------------------------------------------------- /src/window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 27 | 28 | 29 |
30 | 31 | _Preferences 32 | app.preferences 33 | 34 | 35 | _Keyboard Shortcuts 36 | win.show-help-overlay 37 | 38 | 39 | _About boutique 40 | app.about 41 | 42 |
43 |
44 |
45 | -------------------------------------------------------------------------------- /src/lib/terminal.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import re 3 | import asyncio 4 | import threading 5 | from typing import Callable, List, Union 6 | from .utils import log 7 | 8 | _sanitizer = None 9 | def sanitize(_input: str) -> str: 10 | global _sanitizer 11 | 12 | if not _sanitizer: 13 | _sanitizer = re.compile(r'[^0-9a-zA-Z]+') 14 | 15 | return re.sub(_sanitizer, " ", _input) 16 | 17 | def sh(command: Union[str, List[str]], return_stderr=False) -> str: 18 | to_check = command if isinstance(command, str) else ' '.join(command) 19 | 20 | try: 21 | log(f'Running {command}') 22 | 23 | cmd = f'flatpak-spawn --host {command}'.split(' ') if isinstance(command, str) else ['flatpak-spawn', '--host', *command] 24 | output = subprocess.run(cmd, encoding='utf-8', shell=False, check=True, capture_output=True) 25 | output.check_returncode() 26 | except subprocess.CalledProcessError as e: 27 | print(e.stderr) 28 | 29 | if return_stderr: 30 | return e.output 31 | 32 | raise e 33 | 34 | return re.sub(r'\n$', '', output.stdout) 35 | 36 | def threaded_sh(command: Union[str, List[str]], callback: Callable[[str], None]=None, return_stderr=False): 37 | to_check = command if isinstance(command, str) else command[0] 38 | 39 | def run_command(command: str, callback: Callable[[str], None]=None): 40 | try: 41 | output = sh(command, return_stderr) 42 | 43 | if callback: 44 | callback(re.sub(r'\n$', '', output)) 45 | 46 | except subprocess.CalledProcessError as e: 47 | log(e.stderr) 48 | raise e 49 | 50 | thread = threading.Thread(target=run_command, daemon=True, args=(command, callback, )) 51 | thread.start() -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/it.mijorus.boutique-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 12 | 17 | 18 | -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/actions/globe-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 12 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/AppListBoxItem.py: -------------------------------------------------------------------------------- 1 | from urllib import request 2 | from gi.repository import Gtk, Adw, Gdk, GObject, Pango, GLib 3 | from typing import Dict, List, Optional 4 | from ..lib.utils import cleanhtml 5 | from ..lib.async_utils import idle, _async 6 | import re 7 | 8 | from ..models.AppListElement import AppListElement, InstalledStatus 9 | from ..providers.providers_list import providers 10 | 11 | 12 | class AppListBoxItem(Gtk.ListBoxRow): 13 | def __init__(self, list_element: AppListElement, load_icon_from_network=False, alt_sources: List[AppListElement] = [], **kwargs): 14 | super().__init__(**kwargs) 15 | 16 | self._app: AppListElement = list_element 17 | self._alt_sources: List[AppListElement] = alt_sources 18 | 19 | col = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 20 | col.set_css_classes(['app-listbox-item']) 21 | 22 | self.image_container = Gtk.Box() 23 | col.append(self.image_container) 24 | 25 | app_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, valign=Gtk.Align.CENTER) 26 | app_details_box.append( 27 | Gtk.Label( 28 | label=f'{cleanhtml(list_element.name).replace("&", "")}', 29 | halign=Gtk.Align.START, 30 | use_markup=True, 31 | max_width_chars=70, 32 | ellipsize=Pango.EllipsizeMode.END 33 | ) 34 | ) 35 | 36 | desc = list_element.description if len(list_element.description) else '' 37 | app_details_box.append( 38 | Gtk.Label( 39 | label=cleanhtml(desc), 40 | halign=Gtk.Align.START, 41 | lines=1, 42 | max_width_chars=100, 43 | ellipsize=Pango.EllipsizeMode.END, 44 | ) 45 | ) 46 | 47 | self.update_version = Gtk.Label( 48 | label='', 49 | margin_top=3, 50 | halign=Gtk.Align.START, 51 | css_classes=['subtitle'], 52 | visible=False 53 | ) 54 | 55 | app_details_box.append(self.update_version) 56 | app_details_box.set_hexpand(True) 57 | col.append(app_details_box) 58 | 59 | provider_icon_box = Gtk.Button(css_classes=['provider-icon']) 60 | provider_icon = Gtk.Image(resource=providers[list_element.provider].small_icon) 61 | provider_icon.set_pixel_size(18) 62 | provider_icon_box.set_child(provider_icon) 63 | col.append(provider_icon_box) 64 | 65 | self.set_child(col) 66 | 67 | if self._app.installed_status in [InstalledStatus.UPDATING, InstalledStatus.INSTALLING]: 68 | self.set_opacity(0.5) 69 | 70 | def load_icon(self, load_from_network: bool = False): 71 | image = providers[self._app.provider].get_icon(self._app, load_from_network=load_from_network) 72 | image.set_pixel_size(45) 73 | self.image_container.append(image) 74 | 75 | def set_update_version(self, text: Optional[str]): 76 | self.update_version.set_visible(text != None) 77 | self.update_version.set_label(text if text else '') -------------------------------------------------------------------------------- /src/lib/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import time 4 | import logging 5 | import gi 6 | import requests 7 | import hashlib 8 | 9 | gi.require_version('Gtk', '4.0') 10 | gi.require_version('Adw', '1') 11 | 12 | from gi.repository import Gtk, Gio, Adw, Gdk, GLib, GdkPixbuf # noqa 13 | 14 | 15 | def key_in_dict(_dict: dict, key_lookup: str, separator='.'): 16 | """ 17 | Searches for a nested key in a dictionary and returns its value, or None if nothing was found. 18 | key_lookup must be a string where each key is deparated by a given "separator" character, which by default is a dot 19 | """ 20 | keys = key_lookup.split(separator) 21 | subdict = _dict 22 | 23 | for k in keys: 24 | if isinstance(subdict, dict): 25 | subdict = subdict[k] if (k in subdict) else None 26 | 27 | if subdict is None: 28 | break 29 | 30 | return subdict 31 | 32 | 33 | def log(s): 34 | logging.debug(s) 35 | 36 | 37 | def add_page_to_adw_stack(stack: Adw.ViewStack, page: Gtk.Widget, name: str, title: str, icon: str): 38 | stack.add_titled(page, name, title) 39 | stack.get_page(page).set_icon_name(icon) 40 | 41 | 42 | # as per recommendation from @freylis, compile once only 43 | _html_clearner = None 44 | 45 | 46 | def cleanhtml(raw_html: str) -> str: 47 | global _html_clearner 48 | 49 | if not _html_clearner: 50 | _html_clearner = re.compile('<.*?>') 51 | 52 | cleantext = re.sub(_html_clearner, '', raw_html) 53 | return cleantext 54 | 55 | 56 | def gtk_image_from_url(url: str, image: Gtk.Image) -> Gtk.Image: 57 | response = requests.get(url, timeout=10) 58 | response.raise_for_status() 59 | 60 | loader = GdkPixbuf.PixbufLoader() 61 | loader.write_bytes(GLib.Bytes.new(response.content)) 62 | loader.close() 63 | 64 | image.clear() 65 | image.set_from_pixbuf(loader.get_pixbuf()) 66 | return image 67 | 68 | 69 | def set_window_cursor(cursor: str): 70 | for w in Gtk.Window.list_toplevels(): 71 | if isinstance(w, Gtk.ApplicationWindow): 72 | w.set_cursor(Gdk.Cursor.new_from_name(cursor, None)) 73 | break 74 | 75 | 76 | def get_application_window() -> Gtk.ApplicationWindow: 77 | for w in Gtk.Window.list_toplevels(): 78 | if isinstance(w, Gtk.ApplicationWindow): 79 | return w 80 | 81 | 82 | def qq(condition, is_true, is_false): 83 | return is_true if condition else is_false 84 | 85 | 86 | def get_giofile_content_type(file: Gio.File): 87 | return file.query_info('standard::', Gio.FileQueryInfoFlags.NONE, None).get_content_type() 88 | 89 | 90 | def gio_copy(file: Gio.File, destination: Gio.File): 91 | return file.copy( 92 | destination, 93 | Gio.FileCopyFlags.OVERWRITE, 94 | None, None, None, None 95 | ) 96 | 97 | 98 | def get_file_hash(file: Gio.File): 99 | with open(file.get_path(), 'rb') as f: 100 | return hashlib.md5(f.read()).hexdigest() 101 | 102 | 103 | def send_notification(notification=Gio.Notification, tag=None): 104 | if not tag: 105 | tag = str(time.time_ns()) 106 | Gio.Application().get_default().send_notification(tag, notification) 107 | 108 | 109 | def get_gsettings() -> Gio.Settings: 110 | return Gio.Settings.new('it.mijorus.boutique') 111 | 112 | 113 | def create_dict(*args: str): 114 | return dict({i: eval(i) for i in args}) 115 | -------------------------------------------------------------------------------- /src/models/Provider.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List, Callable, Dict, Tuple, Optional, TypeVar 3 | from .AppListElement import AppListElement 4 | from .Models import AppUpdateElement, ProviderMessage 5 | from .AppListElement import InstalledStatus 6 | from gi.repository import Gtk, Gio 7 | 8 | class Provider(ABC): 9 | # refresh_installed_status_callback: Callable 10 | name: str = NotImplemented 11 | icon: str = NotImplemented 12 | smail_icon: str = NotImplemented 13 | general_messages: List[ProviderMessage] = NotImplemented 14 | update_messages: List[ProviderMessage] = NotImplemented 15 | 16 | @abstractmethod 17 | def list_installed(self) -> List[AppListElement]: 18 | pass 19 | 20 | @abstractmethod 21 | def is_installed(self, el: AppListElement, alt_sources: list[AppListElement]=[]) -> Tuple[bool, Optional[AppListElement]]: 22 | pass 23 | 24 | @abstractmethod 25 | def get_icon(self, AppListElement, repo: str=None, load_from_network: bool=False) -> Gtk.Image: 26 | pass 27 | 28 | @abstractmethod 29 | def uninstall(self, el: AppListElement, c: Callable[[bool], None]): 30 | pass 31 | 32 | @abstractmethod 33 | def install(self, el: AppListElement, c: Callable[[bool], None]): 34 | pass 35 | 36 | @abstractmethod 37 | def search(self, query: str) -> List[AppListElement]: 38 | pass 39 | 40 | @abstractmethod 41 | def get_long_description(self, el: AppListElement) -> str: 42 | pass 43 | 44 | @abstractmethod 45 | def load_extra_data_in_appdetails(self, widget: Gtk.Widget, el: AppListElement): 46 | pass 47 | 48 | @abstractmethod 49 | def list_updatables(self) -> List[AppUpdateElement]: 50 | pass 51 | 52 | @abstractmethod 53 | def update(self, el: AppListElement, callback: Callable[[bool], None]): 54 | pass 55 | 56 | @abstractmethod 57 | def update_all(self, callback: Callable[[bool, str, bool], None]): 58 | pass 59 | 60 | @abstractmethod 61 | def updates_need_refresh(self) -> bool: 62 | pass 63 | 64 | @abstractmethod 65 | def run(self, el: AppListElement): 66 | pass 67 | 68 | @abstractmethod 69 | def can_install_file(self, filename: Gio.File) -> bool: 70 | pass 71 | 72 | @abstractmethod 73 | def is_updatable(self, app_id: str) -> bool: 74 | pass 75 | 76 | @abstractmethod 77 | def install_file(self, list_element: AppListElement, callback: Callable[[bool], None]) -> bool: 78 | pass 79 | 80 | @abstractmethod 81 | def create_list_element_from_file(self, file: Gio.File) -> AppListElement: 82 | pass 83 | 84 | @abstractmethod 85 | def open_file_dialog(self, file: Gio.File, parent: Gtk.Widget): 86 | pass 87 | 88 | @abstractmethod 89 | def get_selected_source(self, list_element: list[AppListElement], source_id: str) -> AppListElement: 90 | pass 91 | 92 | @abstractmethod 93 | def get_source_details(self, list_element: AppListElement) -> tuple[str, str]: 94 | pass 95 | 96 | @abstractmethod 97 | def set_refresh_installed_status_callback(self, callback: Optional[Callable[[InstalledStatus, bool], None]]): 98 | pass 99 | 100 | @abstractmethod 101 | def get_previews(self, el: AppListElement) -> List[Gtk.Widget]: 102 | pass 103 | 104 | ## extra details 105 | @abstractmethod 106 | def get_installed_from_source(self, el: AppListElement) -> str: 107 | pass 108 | 109 | @abstractmethod 110 | def get_available_from_labels(self, el: AppListElement) -> list: 111 | pass 112 | -------------------------------------------------------------------------------- /python3-requirements.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "python3-requirements", 3 | "buildsystem": "simple", 4 | "build-commands": [], 5 | "modules": [ 6 | { 7 | "name": "python3-requests", 8 | "buildsystem": "simple", 9 | "build-commands": [ 10 | "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"requests>=2.25.1\" --no-build-isolation" 11 | ], 12 | "sources": [ 13 | { 14 | "type": "file", 15 | "url": "https://files.pythonhosted.org/packages/41/5b/2209eba8133fc081d3ffff02e1f6376e3117e52bb16f674721a83e67e68e/requests-2.28.0-py3-none-any.whl", 16 | "sha256": "bc7861137fbce630f17b03d3ad02ad0bf978c844f3536d0edda6499dafce2b6f" 17 | }, 18 | { 19 | "type": "file", 20 | "url": "https://files.pythonhosted.org/packages/06/b3/24afc8868eba069a7f03650ac750a778862dc34941a4bebeb58706715726/charset_normalizer-2.0.12-py3-none-any.whl", 21 | "sha256": "6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" 22 | }, 23 | { 24 | "type": "file", 25 | "url": "https://files.pythonhosted.org/packages/04/a2/d918dcd22354d8958fe113e1a3630137e0fc8b44859ade3063982eacd2a4/idna-3.3-py3-none-any.whl", 26 | "sha256": "84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff" 27 | }, 28 | { 29 | "type": "file", 30 | "url": "https://files.pythonhosted.org/packages/ec/03/062e6444ce4baf1eac17a6a0ebfe36bb1ad05e1df0e20b110de59c278498/urllib3-1.26.9-py2.py3-none-any.whl", 31 | "sha256": "44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14" 32 | }, 33 | { 34 | "type": "file", 35 | "url": "https://files.pythonhosted.org/packages/e9/06/d3d367b7af6305b16f0d28ae2aaeb86154fa91f144f036c2d5002a5a202b/certifi-2022.6.15-py3-none-any.whl", 36 | "sha256": "fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" 37 | } 38 | ] 39 | }, 40 | { 41 | "name": "python3-html2text", 42 | "buildsystem": "simple", 43 | "build-commands": [ 44 | "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"html2text==2020.1.16\" --no-build-isolation" 45 | ], 46 | "sources": [ 47 | { 48 | "type": "file", 49 | "url": "https://files.pythonhosted.org/packages/ae/88/14655f727f66b3e3199f4467bafcc88283e6c31b562686bf606264e09181/html2text-2020.1.16-py3-none-any.whl", 50 | "sha256": "c7c629882da0cf377d66f073329ccf34a12ed2adf0169b9285ae4e63ef54c82b" 51 | } 52 | ] 53 | }, 54 | { 55 | "name": "python3-pyxdg", 56 | "buildsystem": "simple", 57 | "build-commands": [ 58 | "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pyxdg==0.28\" --no-build-isolation" 59 | ], 60 | "sources": [ 61 | { 62 | "type": "file", 63 | "url": "https://files.pythonhosted.org/packages/e5/8d/cf41b66a8110670e3ad03dab9b759704eeed07fa96e90fdc0357b2ba70e2/pyxdg-0.28-py2.py3-none-any.whl", 64 | "sha256": "bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab" 65 | } 66 | ] 67 | } 68 | ] 69 | } -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | # main.py 2 | # 3 | # Copyright 2022 Lorenzo Paderi 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | 18 | from .lib.terminal import sh 19 | from .lib.utils import log 20 | from .providers.providers_list import providers 21 | from .AboutDialog import AboutDialog 22 | from .BoutiqueWindow import BoutiqueWindow 23 | import sys 24 | import gi 25 | import logging 26 | import subprocess 27 | 28 | gi.require_version('Gtk', '4.0') 29 | gi.require_version('Adw', '1') 30 | 31 | from gi.repository import Gtk, Gio, Adw, Gdk, GLib # noqa 32 | 33 | class BoutiqueApplication(Adw.Application): 34 | """The main application singleton class.""" 35 | 36 | def __init__(self): 37 | super().__init__(application_id='it.mijorus.boutique', flags=Gio.ApplicationFlags.HANDLES_OPEN) 38 | self.create_action('quit', self.quit, ['q']) 39 | self.create_action('about', self.on_about_action) 40 | self.create_action('preferences', self.on_preferences_action) 41 | self.create_action('open_file', self.on_open_file_chooser) 42 | self.create_action('open_log_file', self.on_open_log_file) 43 | self.win = None 44 | 45 | def do_startup(self): 46 | log('\n\n---- Application startup') 47 | Adw.Application.do_startup(self) 48 | 49 | css_provider = Gtk.CssProvider() 50 | css_provider.load_from_resource('/it/mijorus/boutique/assets/style.css') 51 | Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) 52 | 53 | def do_activate(self): 54 | """Called when the application is activated. 55 | 56 | We raise the application's main window, creating it if 57 | necessary. 58 | """ 59 | self.win = self.props.active_window 60 | 61 | if not self.win: 62 | self.win = BoutiqueWindow(application=self) 63 | 64 | self.win.present() 65 | 66 | def do_open(self, files: list[Gio.File], n_files: int, _): 67 | if files: 68 | for p, provider in providers.items(): 69 | if provider.can_install_file(files[0]): 70 | self.win = Adw.ApplicationWindow(application=self, visible=False) 71 | 72 | dialog = provider.open_file_dialog(files[0], self.win) 73 | dialog.connect('response', lambda w, _: self.win.close()) 74 | dialog.show() 75 | break 76 | 77 | # for f in files: 78 | # if isinstance(self.props.active_window, BoutiqueWindow): 79 | # self.props.active_window.on_selected_local_file(f) 80 | # break 81 | 82 | def on_about_action(self, widget, _): 83 | """Callback for the app.about action.""" 84 | about = AboutDialog(self.props.active_window) 85 | about.present() 86 | 87 | def on_preferences_action(self, widget, _): 88 | """Callback for the app.preferences action.""" 89 | print('app.preferences action activated') 90 | 91 | def create_action(self, name, callback, shortcuts=None): 92 | """Add an application action. 93 | 94 | Args: 95 | name: the name of the action 96 | callback: the function to be called when the action is 97 | activated 98 | shortcuts: an optional list of accelerators 99 | """ 100 | action = Gio.SimpleAction.new(name, None) 101 | action.connect("activate", callback) 102 | self.add_action(action) 103 | if shortcuts: 104 | self.set_accels_for_action(f"app.{name}", shortcuts) 105 | 106 | def on_open_file_chooser(self, widget, _): 107 | if not self.win: 108 | return 109 | 110 | def on_open_file_chooser_reponse(widget, id): 111 | selected_file = widget.get_file() 112 | 113 | if selected_file and isinstance(self.props.active_window, BoutiqueWindow): 114 | self.props.active_window.on_selected_local_file(selected_file) 115 | 116 | self.file_chooser_dialog = Gtk.FileChooserNative( 117 | title='Open a file', 118 | action=Gtk.FileChooserAction.OPEN, 119 | transient_for=self.win 120 | ) 121 | 122 | self.file_chooser_dialog.connect('response', on_open_file_chooser_reponse) 123 | self.file_chooser_dialog.show() 124 | 125 | def on_open_log_file(self, widget, _): 126 | if not self.win: 127 | return 128 | 129 | sh(['xdg-open', GLib.get_user_cache_dir() + '/boutique.log']) 130 | 131 | 132 | def main(version): 133 | """The application's entry point.""" 134 | 135 | log_file = GLib.get_user_cache_dir() + '/boutique.log' 136 | print('Logging to file ' + log_file) 137 | 138 | app = BoutiqueApplication() 139 | logging.basicConfig( 140 | filename=log_file, 141 | filemode='a', 142 | encoding='utf-8', 143 | level=logging.DEBUG, 144 | force=True 145 | ) 146 | 147 | return app.run(sys.argv) 148 | -------------------------------------------------------------------------------- /src/BrowseApps.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | from typing import List, Dict 4 | from .lib import flatpak, utils, async_utils 5 | from .models.AppListElement import AppListElement 6 | from .models.Models import SearchResultsItems 7 | from .models.Provider import Provider 8 | from .providers.providers_list import providers 9 | from .components.AppListBoxItem import AppListBoxItem 10 | from .components.CustomComponents import CenteringBox, NoAppsFoundRow 11 | 12 | from gi.repository import Gtk, Adw, GObject, Gio, Gdk 13 | 14 | 15 | class BrowseApps(Gtk.ScrolledWindow): 16 | __gsignals__ = { 17 | "selected-app": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, (object, )), 18 | } 19 | 20 | def __init__(self): 21 | super().__init__() 22 | self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 23 | self.main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, margin_top=20, margin_bottom=20) 24 | 25 | self.search_entry = Gtk.SearchEntry() 26 | self.search_results: Gtk.ListBox = None 27 | self.search_results_items = [] 28 | 29 | self.search_placeholder_text = 'Press "Enter" to search' 30 | self.search_entry.props.placeholder_text = self.search_placeholder_text 31 | self.search_entry.connect('activate', self.on_search_entry_activated) 32 | self.search_entry.connect('search-changed', self.on_search_entry_changed) 33 | 34 | self.main_box.append(self.search_entry) 35 | self.search_tip_revealer = Gtk.Revealer( 36 | transition_type=Gtk.RevealerTransitionType.SLIDE_UP, 37 | child=Gtk.Label(label=self.search_placeholder_text, opacity=0.7), 38 | reveal_child=False 39 | ) 40 | 41 | self.main_box.append(self.search_tip_revealer) 42 | 43 | self.search_results_slot = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL) 44 | self.spinner = Gtk.Box(hexpand=True, halign=Gtk.Align.CENTER, margin_top=10, visible=False) 45 | self.spinner.append(Gtk.Spinner(spinning=True, margin_top=5, margin_bottom=5)) 46 | 47 | self.search_results_slot.append(self.spinner) 48 | self.search_results_slot_placeholder = CenteringBox(hexpand=True, vexpand=True, spacing=5) 49 | self.search_results_slot_placeholder.append(Gtk.Image(icon_name='system-search-symbolic', css_classes=['large-icons'])) 50 | self.search_results_slot_placeholder.append(Gtk.Label(label='Search for apps, games and more...', css_classes=['title-3'])) 51 | 52 | self.search_results_slot.append(self.search_results_slot_placeholder) 53 | 54 | self.main_box.append(self.search_results_slot) 55 | 56 | clamp = Adw.Clamp(child=self.main_box, maximum_size=600, margin_top=10, margin_bottom=20) 57 | self.set_child(clamp) 58 | 59 | def on_activated_row(self, listbox: Gtk.ListBox, row: AppListBoxItem): 60 | """Emit and event that changes the active page of the Stack in the parent widget""" 61 | if not hasattr(row, '_app'): 62 | return 63 | 64 | self.emit('selected-app', (row._app, row._alt_sources)) 65 | 66 | def on_search_entry_activated(self, widget: Gtk.SearchEntry): 67 | query = widget.get_text() 68 | 69 | if self.search_results_slot_placeholder.get_visible(): 70 | self.search_results_slot_placeholder.set_visible(False) 71 | self.search_results_slot.remove(self.search_results_slot_placeholder) 72 | 73 | if self.search_results: 74 | self.search_results_slot.remove(self.search_results) 75 | 76 | if not len(query): 77 | return 78 | 79 | widget.set_text('') 80 | 81 | # Perform search across all the providers 82 | Gio.Application.get_default().mark_busy() 83 | 84 | self.spinner.set_visible(True) 85 | utils.set_window_cursor('wait') 86 | self.search_entry.set_editable(False) 87 | self.populate_search(query) 88 | 89 | def on_search_entry_changed(self, widget): 90 | query = widget.get_text() 91 | 92 | if not self.search_results_slot_placeholder.get_visible(): 93 | self.search_tip_revealer.set_reveal_child(len(query) > 0) 94 | 95 | # @async_utils.idle 96 | @async_utils._async 97 | def populate_search(self, query: str): 98 | """Async function to populate the listbox without affecting the main thread""" 99 | provider_results: List[AppListElement] = [] 100 | for p, provider in providers.items(): 101 | provider_results.extend(provider.search(query)) 102 | 103 | results_dict: dict[str, List[AppListElement]] = {} 104 | results: list[SearchResultsItems] = [] 105 | for app in provider_results: 106 | if (not app.id in results_dict): 107 | results_dict[app.id] = [] 108 | results_dict[app.id].append(app) 109 | 110 | for a, apps in results_dict.items(): 111 | results.append(SearchResultsItems(a, apps)) 112 | 113 | self.search_results = Gtk.ListBox(hexpand=True, margin_top=10) 114 | self.search_results.set_css_classes(['boxed-list']) 115 | 116 | if not results: 117 | self.search_results.append(NoAppsFoundRow()) 118 | 119 | else: 120 | load_img_threads: List[threading.Thread] = [] 121 | 122 | for search_results_items in results: 123 | list_row = AppListBoxItem( 124 | search_results_items.list_elements[0], 125 | alt_sources=search_results_items.list_elements[1:], 126 | activatable=True, 127 | selectable=True, 128 | hexpand=True, 129 | visible=True 130 | ) 131 | 132 | self.search_results.append(list_row) 133 | self.search_results_items.append(list_row) 134 | load_img_threads.append(threading.Thread(target=lambda r: r.load_icon(load_from_network=True), args=(list_row, ))) 135 | 136 | for t in load_img_threads: 137 | t.start() 138 | for t in load_img_threads: 139 | t.join() 140 | 141 | results.clear() 142 | self.spinner.set_visible(False) 143 | self.search_results.connect('row-activated', self.on_activated_row) 144 | self.search_results_slot.append(self.search_results) 145 | self.search_entry.set_editable(True) 146 | utils.set_window_cursor('default') 147 | -------------------------------------------------------------------------------- /src/lib/flatpak.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib 3 | import requests 4 | from typing import List, Callable, Dict, Union, Literal, Optional 5 | from .terminal import sh, threaded_sh, sanitize 6 | from ..models.AppsListSection import AppsListSection 7 | from ..models.Models import FlatpakHistoryElement 8 | from .utils import key_in_dict, log 9 | 10 | API_BASEURL = 'https://flathub.org/api/v2' 11 | FLATHUB_REPO_URL = 'https://dl.flathub.org/repo/' 12 | _columns_query: List[str] = ['name', 'description', 'application', 'version', 'branch', 'arch', 'runtime', 'origin', 'installation', 'ref', 'active', 'latest', 'size'] 13 | 14 | def _parse_output(command_output: str, headers: List[str], to_sort=True) -> List[Dict]: 15 | output: List = [] 16 | for row in command_output.split('\n'): 17 | if not '\t' in row: 18 | break 19 | 20 | columns: List[str] = row.split('\t') 21 | 22 | app_details = {} 23 | for col, h in zip(columns, headers): 24 | app_details[h] = col 25 | 26 | output.append(app_details) 27 | 28 | if to_sort: 29 | output = sorted(output, key=lambda o: o['name'].lower()) 30 | 31 | return output 32 | 33 | def full_list() -> List: 34 | output_list: str = sh(f'flatpak list --user --columns={",".join(_columns_query)}') 35 | 36 | output: List = _parse_output(output_list, _columns_query) 37 | return output 38 | 39 | def apps_list() -> List: 40 | output_list: str = sh(f'flatpak list --app --user --columns={",".join(_columns_query)}') 41 | 42 | output: List = _parse_output(output_list, _columns_query) 43 | return output 44 | 45 | def libs_list() -> List: 46 | output_list: str = sh(f'flatpak list --runtime --columns={",".join(_columns_query)}') 47 | 48 | output: List = _parse_output(output_list, _columns_query) 49 | return output 50 | 51 | def sectioned_list() -> List[AppsListSection]: 52 | output: List[AppsListSection] = [ 53 | AppsListSection('installed', apps_list()), 54 | AppsListSection('libraries', libs_list()) 55 | ] 56 | 57 | return output 58 | 59 | _default_arch = None 60 | def get_default_aarch() -> str: 61 | global _default_arch 62 | 63 | if not _default_arch: 64 | _default_arch = sh('flatpak --default-arch') 65 | 66 | return _default_arch 67 | 68 | def get_ref_origin(ref: str) -> str: 69 | return sh(f'flatpak info {ref} -o') 70 | 71 | def remove(ref: str, kill_id: str=None, callback: Callable=None): 72 | if kill_id: 73 | try: 74 | sh(f'flatpak kill {kill_id}') 75 | except Exception as e: 76 | pass 77 | 78 | threaded_sh(f'flatpak remove {ref} --user -y --no-related', callback) 79 | 80 | def install(repo: str, app_id: str): 81 | sh(f'flatpak install --user -y {repo} {app_id}') 82 | 83 | def search(query: str) -> List[Dict]: 84 | query = query.strip() 85 | query = sanitize(query) 86 | 87 | cols = ['name', 'description', 'application', 'version', 'branch', 'remotes'] 88 | res = sh(['flatpak', 'search', '--user', f'--columns={",".join(cols)}', *query.split(' ')]) 89 | 90 | return _parse_output(res, cols, to_sort=False) 91 | 92 | _cached_remotes: Union[Dict['str', Dict], None] = None 93 | def remotes_list(cache=True) -> Dict['str', Dict]: 94 | global _cached_remotes 95 | 96 | if _cached_remotes and cache: 97 | return _cached_remotes 98 | 99 | cols = [ 'name','title','url','collection','subset','filter','priority','options','comment','description','homepage','icon' ] 100 | result = _parse_output(sh(f'flatpak remotes --user --columns={",".join(cols)}'), cols, False) 101 | 102 | output = {} 103 | for r in result: 104 | output[r['name']] = r 105 | del output[r['name']]['name'] 106 | 107 | _cached_remotes = output 108 | return output 109 | 110 | def is_installed(ref: str) -> bool: 111 | try: 112 | sh(['flatpak', 'info', '-r', ref]) 113 | return True 114 | except Exception as e: 115 | return False 116 | 117 | def get_appstream(app_id, remote=None) -> dict: 118 | if remote == 'flathub': 119 | return requests.get(API_BASEURL + f'/appstream/{ urllib.parse.quote(app_id, safe="") }').json() 120 | 121 | return dict() 122 | 123 | def get_app_history(ref: str, remote: str): 124 | log = sh(f'flatpak remote-info {remote} {ref} --log --user') 125 | history = log.split('History:', maxsplit=1) 126 | 127 | output: List[FlatpakHistoryElement] = [] 128 | for h in history[1].split('\n\n', maxsplit=20): 129 | rows = h.split('\n') 130 | 131 | h_el: Union[FlatpakHistoryElement, False] = FlatpakHistoryElement() 132 | for row in rows: 133 | row = row.strip() 134 | if not len( row ): 135 | h_el = False 136 | break 137 | else: 138 | cols = row.split(':', maxsplit=1) 139 | h_el.__setattr__(cols[0].lower(), cols[1].strip()) 140 | 141 | if h_el: 142 | output.append(h_el) 143 | 144 | return output 145 | 146 | def list_remotes() -> List[Dict]: 147 | headers = [ 'name', 'title', 'url', 'collection', 'subset', 'filter', 'priority', 'options', 'comment', 'description', 'homepage', 'icon', ] 148 | remotes = sh(['flatpak', 'remotes', ('--columns=' + ','.join(headers))]) 149 | return _parse_output(remotes, headers) 150 | 151 | def find_remote_from_url(url: str) -> Optional[str]: 152 | for r in list_remotes(): 153 | if r['url'] == url: 154 | return r['name'] 155 | 156 | return None 157 | 158 | def remote_ls(updates_only=False, cached=False, origin: Optional[str]=None): 159 | h = ['application', 'version', 'origin'] 160 | command_args = ['flatpak', 'remote-ls', '--user'] 161 | 162 | if origin: 163 | command_args.append(origin) 164 | 165 | if updates_only: 166 | command_args.append('--updates') 167 | 168 | if cached: 169 | command_args.append('--cached') 170 | 171 | command_args.append(f'--columns={",".join(h)}') 172 | 173 | output = sh(command_args) 174 | return _parse_output(output, h, False) 175 | 176 | def get_info(ref: str) -> Dict[str, str]: 177 | command_output = sh(['flatpak', 'info', '--user', ref]) 178 | 179 | command_output = command_output.split('ID:', maxsplit=2)[1] 180 | command_output = 'ID:' + command_output 181 | 182 | output = {} 183 | for row in command_output.split('\n'): 184 | row = row.strip() 185 | 186 | if not len(row): 187 | continue 188 | 189 | cols = row.split(':', maxsplit=3) 190 | output[cols[0].strip().lower()] = cols[1].strip() 191 | 192 | return output 193 | -------------------------------------------------------------------------------- /src/UpdatesList.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import asyncio 3 | from urllib import request 4 | from gi.repository import Gtk, Adw, Gdk, GObject, Pango, GLib 5 | from typing import Dict, List, Optional 6 | import re 7 | 8 | from .providers.providers_list import providers 9 | from .models.AppListElement import AppListElement, InstalledStatus 10 | from .models.Provider import Provider 11 | from .models.Models import AppUpdateElement 12 | from .components.FilterEntry import FilterEntry 13 | from .components.CustomComponents import NoAppsFoundRow 14 | from .components.AppListBoxItem import AppListBoxItem 15 | from .lib.utils import set_window_cursor, key_in_dict, log 16 | 17 | 18 | class UpdatesList(Gtk.ScrolledWindow): 19 | def __init__(self): 20 | super().__init__() 21 | self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 22 | 23 | self.busy = False 24 | self.main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 25 | self.no_apps_found_row = NoAppsFoundRow(visible=False) 26 | 27 | messages_row = Gtk.ListBox(css_classes=["boxed-list"], margin_bottom=25) 28 | messages_row.append(Adw.ActionRow(title='Messages', subtitle='reported')) 29 | 30 | # updates row 31 | self.updates_fetched = False 32 | self.updates_row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True) 33 | 34 | # the list box containing all the updatable apps 35 | self.updates_row_list_items = [] 36 | self.updates_row_list = Gtk.ListBox(css_classes=["boxed-list"], margin_bottom=25) 37 | 38 | 39 | self.updates_title_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, hexpand=True) 40 | 41 | self.updates_title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, margin_bottom=10, spacing=5) 42 | self.updates_title_container.append(self.updates_title_row) 43 | 44 | self.updates_row_list_spinner = Gtk.Spinner(spinning=True, margin_start=5, visible=False) 45 | self.updates_title_container.append(self.updates_row_list_spinner) 46 | 47 | self.updates_title_label = Gtk.Label(label='', css_classes=['title-4'], hexpand=True, halign=Gtk.Align.START) 48 | self.updates_title_row.append(self.updates_title_label) 49 | 50 | self.update_all_btn = Gtk.Button(label='Update all', css_classes=['suggested-action'], valign=Gtk.Align.CENTER, visible=False) 51 | self.update_all_btn.connect('clicked', self.on_update_all_btn_clicked) 52 | 53 | self.updates_title_row.append(self.update_all_btn) 54 | self.updates_row.append(self.updates_title_container) 55 | self.updates_row.append(self.updates_row_list) 56 | 57 | self.main_box.append(messages_row) 58 | self.main_box.append(self.updates_row) 59 | 60 | clamp = Adw.Clamp(child=self.main_box, maximum_size=600, margin_top=20, margin_bottom=20) 61 | self.set_child(clamp) 62 | 63 | def on_show(self, only_provider: Optional[str] = None): 64 | if self.busy: 65 | return 66 | 67 | self.busy = True 68 | 69 | for widget in self.updates_row_list_items: 70 | self.updates_row_list.remove(widget) 71 | 72 | self.toggle_updates_title_label_state('Searching for updates...', True) 73 | self.updates_row_list_spinner.set_visible(True) 74 | self.updates_row_list.set_css_classes(["boxed-list"]) 75 | 76 | threading.Thread(target=self.async_load_upgradable, daemon=True).start() 77 | 78 | # Runs the background task to check for app updates 79 | def async_load_upgradable(self, only_provider: Optional[str] = None): 80 | updatable_elements = [] 81 | 82 | for p, provider in providers.items(): 83 | apps = provider.list_installed() 84 | 85 | for upg in provider.list_updatables(): 86 | for a in apps: 87 | if (a.id == upg.id): 88 | upg.extra_data['app_list_element'] = a 89 | 90 | if a.extra_data and upg.to_version: 91 | if ('version' in a.extra_data) and (a.extra_data['version'] != upg.to_version): 92 | upg.to_version = a.extra_data['version'] + ' > ' + upg.to_version 93 | 94 | updatable_elements.append(upg) 95 | break 96 | 97 | GLib.idle_add(self.render_updatables, updatable_elements) 98 | 99 | def render_updatables(self, updatable_elements: List[AppUpdateElement]): 100 | upgradable_count = 0 101 | 102 | for upg in updatable_elements: 103 | update_is_an_app = False 104 | 105 | list_element = upg.extra_data['app_list_element'] 106 | 107 | app_list_item = AppListBoxItem(list_element, activatable=False, selectable=False, hexpand=True) 108 | if upg.to_version: 109 | app_list_item.set_update_version(upg.to_version) 110 | 111 | GLib.idle_add(app_list_item.load_icon) 112 | 113 | self.updates_row_list.append(app_list_item) 114 | self.updates_row_list_items.append(app_list_item) 115 | 116 | self.updates_fetched = True 117 | self.updates_row_list_spinner.set_visible(False) 118 | 119 | if updatable_elements: 120 | self.toggle_updates_title_label_state('Available updates', False) 121 | else: 122 | self.toggle_updates_title_label_state('Everything is up to date!', True) 123 | 124 | self.busy = False 125 | 126 | def toggle_updates_title_label_state(self, label: str, show_as_title: bool): 127 | self.updates_title_label.set_label(label) 128 | 129 | if show_as_title: 130 | self.update_all_btn.set_visible(False) 131 | self.updates_title_label.set_css_classes(['title-3']) 132 | 133 | self.updates_title_container.set_vexpand(True) 134 | self.updates_title_container.set_halign(Gtk.Align.CENTER) 135 | self.updates_title_container.set_valign(Gtk.Align.CENTER) 136 | 137 | self.updates_row_list.set_visible(False) 138 | else: 139 | self.updates_row_list.set_visible(True) 140 | 141 | self.updates_title_label.set_css_classes(['title-4']) 142 | 143 | self.updates_title_container.set_vexpand(False) 144 | self.updates_title_container.set_halign(Gtk.Align.FILL) 145 | self.updates_title_container.set_halign(Gtk.Align.FILL) 146 | self.updates_title_container.set_valign(Gtk.Align.START) 147 | 148 | self.update_all_btn.set_visible(True) 149 | 150 | def after_update_all(self, result: bool, prov: str): 151 | if result and (not self.update_all_btn.has_css_class('destructive-action')): 152 | if self.updates_row_list and prov == [*providers.keys()][-1]: 153 | self.updates_row_list.set_opacity(1) 154 | self.update_all_btn.set_sensitive(True) 155 | self.update_all_btn.set_label('Update all') 156 | 157 | else: 158 | self.update_all_btn.set_label('Error') 159 | self.update_all_btn.set_css_classes(['destructive-action']) 160 | 161 | self.refresh_upgradable(only_provider=prov) 162 | 163 | def on_update_all_btn_clicked(self, widget: Gtk.Button): 164 | if not self.updates_row_list: 165 | return 166 | 167 | self.updates_row_list.set_opacity(0.5) 168 | self.update_all_btn.set_sensitive(False) 169 | self.update_all_btn.set_label('Updating...') 170 | 171 | for p, provider in providers.items(): 172 | provider.update_all(self.after_update_all) 173 | -------------------------------------------------------------------------------- /src/BoutiqueWindow.py: -------------------------------------------------------------------------------- 1 | # window.py 2 | # 3 | # Copyright 2022 Lorenzo Paderi 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | 18 | from .InstalledAppsList import InstalledAppsList 19 | from .BrowseApps import BrowseApps 20 | from .UpdatesList import UpdatesList 21 | from .AppDetails import AppDetails 22 | from .models.AppListElement import AppListElement 23 | from .State import state 24 | from .lib import flatpak, utils 25 | 26 | from gi.repository import Gtk, Adw, Gio 27 | 28 | 29 | class BoutiqueWindow(Gtk.ApplicationWindow): 30 | def __init__(self, **kwargs): 31 | super().__init__(**kwargs) 32 | 33 | # Create a container stack 34 | self.container_stack = Gtk.Stack() 35 | 36 | # Create the "main_stack" widget we will be using in the Window 37 | self.app_lists_stack = Adw.ViewStack() 38 | 39 | self.titlebar = Adw.HeaderBar() 40 | self.view_title_widget = Adw.ViewSwitcherTitle(stack=self.app_lists_stack) 41 | self.left_button = Gtk.Button(icon_name='go-previous', visible=False) 42 | 43 | menu_obj = Gtk.Builder.new_from_resource('/it/mijorus/boutique/gtk/main-menu.xml') 44 | self.menu_button = Gtk.MenuButton(icon_name='open-menu', menu_model=menu_obj.get_object('primary_menu')) 45 | 46 | self.titlebar.pack_start(self.left_button) 47 | self.titlebar.pack_end(self.menu_button) 48 | 49 | self.titlebar.set_title_widget(self.view_title_widget) 50 | self.set_titlebar(self.titlebar) 51 | 52 | self.set_title('Boutique') 53 | self.set_default_size(700, 700) 54 | 55 | # Create the "stack" widget for the "installed apps" view 56 | self.installed_stack = Gtk.Stack() 57 | self.app_details = AppDetails() 58 | 59 | self.installed_apps_list = InstalledAppsList() 60 | self.installed_stack.add_child(self.installed_apps_list) 61 | 62 | self.installed_stack.set_visible_child(self.installed_apps_list) 63 | 64 | # Create the "stack" widget for the browse view 65 | self.browse_stack = Gtk.Stack() 66 | self.browse_apps = BrowseApps() 67 | 68 | self.browse_stack.add_child(self.browse_apps) 69 | 70 | self.updates_stack = Gtk.Stack() 71 | self.updates_list = UpdatesList() 72 | self.updates_stack.add_child(self.updates_list) 73 | 74 | # Add content to the main_stack 75 | utils.add_page_to_adw_stack(self.app_lists_stack, self.installed_stack, 'installed', 'Installed', 'computer-symbolic' ) 76 | utils.add_page_to_adw_stack(self.app_lists_stack, self.updates_stack, 'updates', 'Updates' , 'software-update-available-symbolic') 77 | utils.add_page_to_adw_stack(self.app_lists_stack, self.browse_stack, 'browse', 'Browse' , 'globe-symbolic') 78 | 79 | self.container_stack.add_child(self.app_lists_stack) 80 | self.container_stack.add_child(self.app_details) 81 | self.set_child(self.container_stack) 82 | 83 | # Show details of an installed app 84 | self.installed_apps_list.connect('selected-app', self.on_selected_installed_app) 85 | self.app_details.connect('refresh-updatable', lambda _: self.installed_apps_list.refresh_upgradable()) 86 | # Show details of an app from global search 87 | self.browse_apps.connect('selected-app', self.on_selected_browsed_app) 88 | 89 | # # come back to the list from the app details window 90 | # self.app_details.connect('show_list', self.on_show_installed_list) 91 | 92 | # left arrow click 93 | self.left_button.connect('clicked', self.on_left_button_clicked) 94 | # change visible child of the app list stack 95 | self.app_lists_stack.connect('notify::visible-child', self.on_app_lists_stack_change) 96 | # change visible child of the container stack 97 | self.container_stack.connect('notify::visible-child', self.on_container_stack_change) 98 | 99 | def on_selected_installed_app(self, source: Gtk.Widget, list_element: AppListElement): 100 | """Show app details""" 101 | 102 | self.app_details.set_app_list_element(list_element) 103 | self.container_stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) 104 | self.container_stack.set_visible_child(self.app_details) 105 | 106 | def on_selected_browsed_app(self, source: Gtk.Widget, custom_event: tuple[AppListElement, list[AppListElement]]): 107 | """Show details for an app from global search""" 108 | list_element, alt_sources = custom_event 109 | 110 | self.app_details.set_app_list_element(list_element, load_icon_from_network=True, alt_sources=alt_sources) 111 | # self.app_details.set_alt_sources(alt_sources) 112 | self.container_stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) 113 | self.container_stack.set_visible_child(self.app_details) 114 | 115 | def on_selected_local_file(self, file: Gio.File): 116 | if self.app_details.set_from_local_file(file): 117 | self.container_stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) 118 | self.container_stack.set_visible_child(self.app_details) 119 | else: 120 | utils.send_notification( 121 | Gio.Notification.new('Unsupported file type: Boutique can\'t handle these types of files.') 122 | ) 123 | 124 | def on_show_installed_list(self, source: Gtk.Widget=None, _=None): 125 | self.container_stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT) 126 | self.left_button.set_visible(False) 127 | 128 | self.installed_apps_list.refresh_list() 129 | self.installed_apps_list.refresh_upgradable() 130 | self.container_stack.set_visible_child(self.app_lists_stack) 131 | 132 | def on_show_browsed_list(self, source: Gtk.Widget=None, _=None): 133 | self.container_stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT) 134 | self.left_button.set_visible(False) 135 | 136 | self.container_stack.set_visible_child(self.app_lists_stack) 137 | 138 | def on_left_button_clicked(self, widget): 139 | if self.app_lists_stack.get_visible_child() == self.installed_stack: 140 | if self.container_stack.get_visible_child() == self.app_details: 141 | self.titlebar.set_title_widget(self.view_title_widget) 142 | self.on_show_installed_list() 143 | 144 | elif self.app_lists_stack.get_visible_child() == self.browse_stack: 145 | if self.container_stack.get_visible_child() == self.app_details: 146 | self.titlebar.set_title_widget(self.view_title_widget) 147 | self.on_show_browsed_list() 148 | 149 | def on_app_lists_stack_change(self, widget, _): 150 | if self.app_lists_stack.get_visible_child() == self.updates_stack: 151 | self.updates_list.on_show() 152 | 153 | def on_container_stack_change(self, widget, _): 154 | in_app_details = self.container_stack.get_visible_child() == self.app_details 155 | self.left_button.set_visible(in_app_details) 156 | self.view_title_widget.set_visible(not in_app_details) -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/apps/it.mijorus.boutique.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /src/assets/App-image-logo-bw.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 24 | 28 | 32 | 33 | 35 | 39 | 43 | 44 | 46 | 50 | 54 | 55 | 57 | 61 | 65 | 66 | 68 | 72 | 76 | 77 | 79 | 83 | 87 | 88 | 90 | 94 | 98 | 99 | 109 | 111 | 115 | 119 | 120 | 130 | 132 | 136 | 140 | 141 | 149 | 151 | 155 | 159 | 163 | 164 | 174 | 175 | 197 | 199 | 200 | 202 | image/svg+xml 203 | 205 | 206 | 207 | 208 | 212 | 221 | 230 | 234 | 238 | 239 | 240 | -------------------------------------------------------------------------------- /src/InstalledAppsList.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import asyncio 3 | from urllib import request 4 | from gi.repository import Gtk, Adw, Gdk, GObject, Pango 5 | from typing import Dict, List, Optional 6 | import re 7 | 8 | from .providers.providers_list import providers 9 | from .models.AppListElement import AppListElement, InstalledStatus 10 | from .models.Provider import Provider 11 | from .models.Models import AppUpdateElement 12 | from .components.FilterEntry import FilterEntry 13 | from .components.CustomComponents import NoAppsFoundRow 14 | from .components.AppListBoxItem import AppListBoxItem 15 | from .lib.utils import set_window_cursor, key_in_dict, log 16 | 17 | class InstalledAppsList(Gtk.ScrolledWindow): 18 | __gsignals__ = { 19 | "selected-app": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, (object, )), 20 | } 21 | 22 | def __init__(self): 23 | super().__init__() 24 | self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 25 | 26 | self.main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 27 | 28 | self.installed_apps_list_slot = Gtk.Box() 29 | self.installed_apps_list: Optional[Gtk.ListBox] = None 30 | self.installed_apps_list_rows: List[Gtk.ListBoxRow] = [] 31 | self.no_apps_found_row = NoAppsFoundRow(visible=False) 32 | 33 | # Create the filter search bar 34 | self.filter_query: str = '' 35 | self.filter_entry = FilterEntry('Filter installed applications', capture=self, margin_bottom=20) 36 | self.filter_entry.connect('search-changed', self.trigger_filter_list) 37 | 38 | self.refresh_list() 39 | 40 | # updates row 41 | self.updates_fetched = False 42 | self.updates_row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True) 43 | 44 | ## the list box containing all the updatable apps 45 | self.updates_row_list = Gtk.ListBox(css_classes=["boxed-list"], margin_bottom=25) 46 | self.updates_row_list_spinner = Gtk.ListBoxRow(child=Gtk.Spinner(spinning=True, margin_top=5, margin_bottom=5), visible=False) 47 | self.updates_row_list.append(self.updates_row_list_spinner) 48 | self.updates_row_list.connect('row-activated', self.on_activated_row) 49 | ## an array containing all the updatable apps, used for some custom login 50 | self.updates_row_list_items: list = [] 51 | self.updates_revealter = Gtk.Revealer(child=self.updates_row, transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, reveal_child=False) 52 | 53 | updates_title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, valign=Gtk.Align.CENTER, margin_bottom=5) 54 | 55 | self.updates_title_label = Gtk.Label(label='', css_classes=['title-4'], hexpand=True, halign=Gtk.Align.START) 56 | updates_title_row.append( self.updates_title_label ) 57 | 58 | self.update_all_btn = Gtk.Button(label='Update all', css_classes=['suggested-action'], valign=Gtk.Align.CENTER) 59 | self.update_all_btn.connect('clicked', self.on_update_all_btn_clicked) 60 | 61 | updates_title_row.append(self.update_all_btn) 62 | self.updates_row.append(updates_title_row) 63 | self.updates_row.append(self.updates_row_list) 64 | 65 | # title row 66 | title_row = Gtk.Box(margin_bottom=5) 67 | title_row.append( Gtk.Label(label='Installed applications', css_classes=['title-2']) ) 68 | 69 | for el in [self.filter_entry, self.updates_revealter, title_row, self.installed_apps_list_slot]: 70 | self.main_box.append(el) 71 | 72 | clamp = Adw.Clamp(child=self.main_box, maximum_size=600, margin_top=20, margin_bottom=20) 73 | 74 | self.refresh_upgradable() 75 | self.set_child(clamp) 76 | 77 | def on_activated_row(self, listbox, row: Gtk.ListBoxRow): 78 | """Emit and event that changes the active page of the Stack in the parent widget""" 79 | if not self.update_all_btn.get_sensitive() or not self.updates_fetched: 80 | return 81 | 82 | self.emit('selected-app', row._app) 83 | 84 | def refresh_list(self): 85 | set_window_cursor('wait') 86 | if self.installed_apps_list: 87 | self.installed_apps_list_slot.remove(self.installed_apps_list) 88 | 89 | self.installed_apps_list= Gtk.ListBox(css_classes=["boxed-list"]) 90 | self.installed_apps_list_rows = [] 91 | 92 | for p, provider in providers.items(): 93 | installed: List[AppListElement] = provider.list_installed() 94 | 95 | for i in installed: 96 | list_row = AppListBoxItem(i, activatable=True, selectable=True, hexpand=True) 97 | list_row.set_update_version(key_in_dict(i.extra_data, 'version')) 98 | 99 | list_row.load_icon(load_from_network=False) 100 | self.installed_apps_list_rows.append(list_row) 101 | self.installed_apps_list.append(list_row) 102 | 103 | self.installed_apps_list.append(self.no_apps_found_row) 104 | self.no_apps_found_row.set_visible(False) 105 | self.installed_apps_list_slot.append(self.installed_apps_list) 106 | 107 | self.installed_apps_list.set_sort_func(lambda r1, r2: self.sort_installed_apps_list(r1, r2)) 108 | self.installed_apps_list.invalidate_sort() 109 | 110 | self.installed_apps_list.connect('row-activated', self.on_activated_row) 111 | set_window_cursor('default') 112 | 113 | def trigger_filter_list(self, widget): 114 | """ Implements a custom filter function""" 115 | if not self.installed_apps_list: 116 | return 117 | 118 | self.filter_query = widget.get_text() 119 | # self.installed_apps_list.invalidate_filter() 120 | 121 | for row in self.installed_apps_list_rows: 122 | if not getattr(row, 'force_show', False) and row._app.installed_status != InstalledStatus.INSTALLED: 123 | row.set_visible(False) 124 | continue 125 | 126 | if not len(self.filter_query): 127 | row.set_visible(True) 128 | continue 129 | 130 | visible = self.filter_query.lower().replace(' ', '') in row._app.name.lower() 131 | row.set_visible(visible) 132 | continue 133 | 134 | self.no_apps_found_row.set_visible(True) 135 | for row in self.installed_apps_list_rows: 136 | if row.get_visible(): 137 | self.no_apps_found_row.set_visible(False) 138 | break 139 | 140 | def _refresh_upgradable_thread(self, only_provider: Optional[str]=None): 141 | """Runs the background task to check for app updates""" 142 | for widget in self.updates_row_list_items: 143 | self.updates_row_list.remove(widget) 144 | 145 | self.updates_row_list_items = [] 146 | 147 | if self.installed_apps_list: 148 | self.installed_apps_list.set_opacity(0.5) 149 | 150 | self.updates_revealter.set_reveal_child(not self.updates_fetched) 151 | self.updates_title_label.set_label('Searching for updates...') 152 | 153 | upgradable = 0 154 | self.updates_row_list_spinner.set_visible(True) 155 | 156 | for p, provider in providers.items(): 157 | updatable_elements = provider.list_updatables() 158 | 159 | updatable_libs: list[AppUpdateElement] = [] 160 | for upg in updatable_elements: 161 | update_is_an_app = False 162 | 163 | for row in self.installed_apps_list_rows: 164 | if row._app.id == upg.id: 165 | update_is_an_app = True 166 | 167 | upgradable += 1 168 | app_list_item = AppListBoxItem(row._app, activatable=True, selectable=True, hexpand=True) 169 | app_list_item.force_show = True 170 | 171 | if upg.to_version and ('version' in row._app.extra_data): 172 | app_list_item.set_update_version(f'{row._app.extra_data["version"]} > {upg.to_version}') 173 | 174 | app_list_item.load_icon() 175 | self.updates_row_list.append( app_list_item ) 176 | self.updates_row_list_items.append( app_list_item ) 177 | row._app.set_installed_status(InstalledStatus.UPDATE_AVAILABLE) 178 | break 179 | 180 | if not update_is_an_app: 181 | updatable_libs.append(upg) 182 | 183 | if updatable_libs: 184 | upgradable += 1 185 | updatable_libs_desc = ', '.join([upl.id for upl in updatable_libs]) 186 | 187 | lib_list_element = AppListElement( 188 | 'System libraries', 189 | 'The following libraries can be updated: ' + updatable_libs_desc, 190 | '__updatable_libs__', 191 | 'flatpak', 192 | InstalledStatus.UPDATE_AVAILABLE 193 | ) 194 | 195 | app_list_item = AppListBoxItem(lib_list_element, activatable=False, selectable=False, hexpand=True) 196 | app_list_item.force_show = True 197 | 198 | if upg.to_version and ('version' in row._app.extra_data): 199 | app_list_item.set_update_version(f'{row._app.extra_data["version"]} > {upg.to_version}') 200 | 201 | app_list_item.load_icon() 202 | self.updates_row_list.append( app_list_item ) 203 | self.updates_row_list_items.append( app_list_item ) 204 | row._app.set_installed_status(InstalledStatus.UPDATE_AVAILABLE) 205 | 206 | 207 | self.updates_fetched = True 208 | self.updates_row_list_spinner.set_visible(False) 209 | self.installed_apps_list.set_opacity(1) 210 | self.updates_revealter.set_reveal_child(upgradable > 0) 211 | self.updates_title_label.set_label('Available updates') 212 | self.trigger_filter_list(self.filter_entry) 213 | 214 | def refresh_upgradable(self, only_provider: Optional[str]=None): 215 | self.updates_fetched = True 216 | # for p, provider in providers.items(): 217 | # if provider.updates_need_refresh(): 218 | # thread = threading.Thread(target=self._refresh_upgradable_thread, args=(only_provider, ), daemon=True) 219 | # thread.start() 220 | 221 | # break 222 | 223 | def after_update_all(self, result: bool, prov: str): 224 | if result and (not self.update_all_btn.has_css_class('destructive-action')): 225 | if self.updates_row_list and prov == [*providers.keys()][-1]: 226 | self.updates_row_list.set_opacity(1) 227 | self.update_all_btn.set_sensitive(True) 228 | self.update_all_btn.set_label('Update all') 229 | 230 | else: 231 | self.update_all_btn.set_label('Error') 232 | self.update_all_btn.set_css_classes(['destructive-action']) 233 | 234 | self.refresh_upgradable(only_provider=prov) 235 | 236 | def on_update_all_btn_clicked(self, widget: Gtk.Button): 237 | if not self.updates_row_list: 238 | return 239 | 240 | self.updates_row_list.set_opacity(0.5) 241 | self.update_all_btn.set_sensitive(False) 242 | self.update_all_btn.set_label('Updating...') 243 | 244 | for p, provider in providers.items(): 245 | provider.update_all(self.after_update_all) 246 | 247 | def sort_installed_apps_list(self, row: AppListBoxItem, row1: AppListBoxItem): 248 | if (not hasattr(row1, '_app')): 249 | return 1 250 | 251 | if (not hasattr(row, '_app')) or (row._app.name.lower() < row1._app.name.lower()): 252 | return -1 253 | 254 | return 1 -------------------------------------------------------------------------------- /src/AppDetails.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import logging 4 | from typing import Optional 5 | from .lib.utils import qq 6 | from gi.repository import Gtk, GObject, Adw, Gdk, Gio, Pango, GLib 7 | from .State import state 8 | from .models.AppListElement import AppListElement, InstalledStatus 9 | from .models.Provider import Provider 10 | from .providers import FlatpakProvider 11 | from .providers.providers_list import providers 12 | from .lib.async_utils import _async, idle 13 | from .lib.utils import cleanhtml, key_in_dict, set_window_cursor, get_application_window 14 | from .components.CustomComponents import CenteringBox, LabelStart 15 | 16 | 17 | class AppDetails(Gtk.ScrolledWindow): 18 | """The presentation screen for an application""" 19 | __gsignals__ = { 20 | "refresh-updatable": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), 21 | } 22 | 23 | def __init__(self): 24 | super().__init__() 25 | self.app_list_element: AppListElement = None 26 | self.active_alt_source: Optional[AppListElement] = None 27 | self.alt_sources: list[AppListElement] = [] 28 | 29 | self.main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, margin_top=10, margin_bottom=10, margin_start=20, margin_end=20,) 30 | 31 | # 1st row 32 | self.details_row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) 33 | self.icon_slot = Gtk.Box() 34 | 35 | title_col = CenteringBox(orientation=Gtk.Orientation.VERTICAL, hexpand=True, spacing=2) 36 | self.title = Gtk.Label(label='', css_classes=['title-1'], hexpand=True, halign=Gtk.Align.CENTER) 37 | self.version = Gtk.Label(label='', halign=Gtk.Align.CENTER, css_classes=['dim-label']) 38 | self.app_id = Gtk.Label( 39 | label='', 40 | halign=Gtk.Align.CENTER, 41 | css_classes=['dim-label'], 42 | ellipsize=Pango.EllipsizeMode.END, 43 | max_width_chars=100, 44 | ) 45 | 46 | for el in [self.title, self.app_id, self.version]: 47 | title_col.append(el) 48 | 49 | self.source_selector_hdlr = None 50 | self.source_selector = Gtk.ComboBoxText() 51 | self.source_selector_revealer = Gtk.Revealer(child=self.source_selector, transition_type=Gtk.RevealerTransitionType.CROSSFADE) 52 | 53 | self.primary_action_button = Gtk.Button(label='Install', valign=Gtk.Align.CENTER) 54 | self.secondary_action_button = Gtk.Button(label='', valign=Gtk.Align.CENTER, visible=False) 55 | 56 | # Action buttons 57 | action_buttons_row = CenteringBox(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) 58 | self.primary_action_button.connect('clicked', self.on_primary_action_button_clicked) 59 | self.secondary_action_button.connect('clicked', self.on_secondary_action_button_clicked) 60 | 61 | for el in [self.secondary_action_button, self.primary_action_button]: 62 | action_buttons_row.append(el) 63 | 64 | for el in [self.icon_slot, title_col, action_buttons_row]: 65 | self.details_row.append(el) 66 | 67 | # preview row 68 | self.previews_row = Gtk.Box( 69 | orientation=Gtk.Orientation.VERTICAL, 70 | spacing=10, 71 | margin_top=20, 72 | ) 73 | 74 | # row 75 | self.desc_row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, margin_top=20) 76 | self.description = Gtk.Label(label='', halign=Gtk.Align.START, wrap=True, selectable=True) 77 | 78 | self.desc_row_spinner = Gtk.Spinner(spinning=False, visible=False) 79 | self.desc_row.append(self.desc_row_spinner) 80 | 81 | self.desc_row.append(self.description) 82 | 83 | # row 84 | self.third_row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 85 | self.extra_data = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 86 | self.third_row.append(self.extra_data) 87 | 88 | for el in [self.details_row, self.previews_row, self.desc_row, self.third_row]: 89 | self.main_box.append(el) 90 | 91 | clamp = Adw.Clamp(child=self.main_box, maximum_size=600, margin_top=10, margin_bottom=20) 92 | self.set_child(clamp) 93 | 94 | self.loading_thread = False 95 | 96 | def set_app_list_element(self, el: AppListElement, load_icon_from_network=False, local_file=False, alt_sources: list[AppListElement] = []): 97 | self.app_list_element = el 98 | self.active_alt_source = None 99 | self.alt_sources = alt_sources 100 | self.local_file = local_file 101 | self.provider = providers[el.provider] 102 | self.load_icon_from_network = load_icon_from_network 103 | 104 | is_installed, alt_list_element_installed = self.provider.is_installed(self.app_list_element, self.alt_sources) 105 | self.load(is_installed, False) 106 | 107 | def load(self, is_installed: bool, alt_list_element_installed): 108 | icon = self.provider.get_icon(self.app_list_element, load_from_network=self.load_icon_from_network) 109 | 110 | self.details_row.remove(self.icon_slot) 111 | self.icon_slot = icon 112 | icon.set_pixel_size(128) 113 | self.details_row.prepend(self.icon_slot) 114 | 115 | self.title.set_label(cleanhtml(self.app_list_element.name)) 116 | 117 | version_label = key_in_dict(self.app_list_element.extra_data, 'version') 118 | self.version.set_markup('' if not version_label else f'{version_label}') 119 | self.app_id.set_markup(f'{self.app_list_element.id}') 120 | self.description.set_label('') 121 | 122 | self.third_row.remove(self.extra_data) 123 | self.extra_data = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 124 | self.third_row.append(self.extra_data) 125 | 126 | self.show_row_spinner(True) 127 | self.load_description() 128 | 129 | self.install_button_label_info = None 130 | 131 | self.load_extra_details() 132 | self.provider.load_extra_data_in_appdetails(self.extra_data, self.app_list_element) 133 | 134 | self.install_button_label_info = None 135 | 136 | self.source_selector.remove_all() 137 | if self.source_selector_hdlr: 138 | self.source_selector.disconnect(self.source_selector_hdlr) 139 | 140 | # if self.alt_sources: 141 | # self.source_selector_revealer.set_reveal_child(True) 142 | # active_source_id = None 143 | # for i, alt_source in enumerate([self.app_list_element, *alt_sources]): 144 | # source_id, source_title = self.provider.get_source_details(alt_source) 145 | # self.source_selector.append(source_id, f'Install from: {source_title}') 146 | # if i == 0: active_source_id = source_id 147 | 148 | # self.source_selector.set_active_id( active_source_id ) 149 | # get_application_window().titlebar.set_title_widget(self.source_selector_revealer) 150 | # else: 151 | # self.source_selector_revealer.set_reveal_child(False) 152 | 153 | # self.source_selector_hdlr = self.source_selector.connect('changed', self.on_source_selector_changed) 154 | self.update_installation_status(check_installed=True) 155 | self.provider.set_refresh_installed_status_callback(self.provider_refresh_installed_status) 156 | 157 | def set_from_local_file(self, file: Gio.File): 158 | for p, provider in providers.items(): 159 | if provider.can_install_file(file): 160 | list_element = provider.create_list_element_from_file(file) 161 | self.set_app_list_element(list_element, True, True) 162 | return True 163 | 164 | logging.debug('Trying to open an unsupported file') 165 | return False 166 | 167 | def on_primary_action_button_clicked(self, button: Gtk.Button): 168 | if self.app_list_element.installed_status == InstalledStatus.INSTALLED: 169 | self.app_list_element.set_installed_status(InstalledStatus.UNINSTALLING) 170 | self.update_installation_status() 171 | 172 | self.provider.uninstall( 173 | self.app_list_element, 174 | self.update_status_callback 175 | ) 176 | 177 | elif self.app_list_element.installed_status == InstalledStatus.UNINSTALLING: 178 | pass 179 | 180 | elif self.app_list_element.installed_status == InstalledStatus.NOT_INSTALLED: 181 | self.app_list_element.set_installed_status(InstalledStatus.INSTALLING) 182 | self.update_installation_status() 183 | 184 | if self.local_file: 185 | self.provider.install_file( 186 | self.active_alt_source or self.app_list_element, 187 | self.update_status_callback 188 | ) 189 | else: 190 | self.provider.install( 191 | self.active_alt_source or self.app_list_element, 192 | self.update_status_callback 193 | ) 194 | 195 | elif self.app_list_element.installed_status == InstalledStatus.UPDATE_AVAILABLE: 196 | self.provider.uninstall( 197 | self.app_list_element, 198 | self.update_status_callback 199 | ) 200 | 201 | def on_secondary_action_button_clicked(self, button: Gtk.Button): 202 | if self.app_list_element.installed_status == InstalledStatus.INSTALLED: 203 | self.provider.run(self.app_list_element) 204 | elif self.app_list_element.installed_status == InstalledStatus.UPDATE_AVAILABLE: 205 | self.app_list_element.set_installed_status(InstalledStatus.UPDATING) 206 | self.update_installation_status() 207 | self.provider.update( 208 | self.app_list_element, 209 | lambda result: self.update_installation_status() 210 | ) 211 | 212 | def update_status_callback(self, status: bool): 213 | if not status: 214 | self.app_list_element.set_installed_status(InstalledStatus.ERROR) 215 | 216 | self.update_installation_status() 217 | 218 | def update_installation_status(self, check_installed=False): 219 | self.primary_action_button.set_css_classes([]) 220 | self.secondary_action_button.set_visible(False) 221 | self.secondary_action_button.set_css_classes([]) 222 | self.source_selector.set_visible(False) 223 | 224 | if check_installed: 225 | if self.provider.is_updatable(self.app_list_element.id): 226 | self.app_list_element.installed_status = InstalledStatus.UPDATE_AVAILABLE 227 | 228 | else: 229 | is_installed, _ = self.provider.is_installed(self.app_list_element) 230 | self.app_list_element.installed_status = qq(is_installed, InstalledStatus.INSTALLED, InstalledStatus.NOT_INSTALLED) 231 | 232 | if self.app_list_element.installed_status == InstalledStatus.INSTALLED: 233 | self.secondary_action_button.set_label('Open') 234 | self.secondary_action_button.set_visible(True) 235 | 236 | self.primary_action_button.set_label('Uninstall') 237 | self.primary_action_button.set_css_classes(['destructive-action']) 238 | 239 | elif self.app_list_element.installed_status == InstalledStatus.UNINSTALLING: 240 | self.primary_action_button.set_label('Uninstalling...') 241 | 242 | elif self.app_list_element.installed_status == InstalledStatus.INSTALLING: 243 | self.primary_action_button.set_label('Installing...') 244 | 245 | elif self.app_list_element.installed_status == InstalledStatus.NOT_INSTALLED: 246 | self.source_selector.set_visible(True) 247 | self.primary_action_button.set_css_classes(['suggested-action']) 248 | 249 | if self.install_button_label_info: 250 | self.primary_action_button.set_label(self.install_button_label_info) 251 | else: 252 | self.primary_action_button.set_label('Install') 253 | 254 | elif self.app_list_element.installed_status == InstalledStatus.UPDATE_AVAILABLE: 255 | self.secondary_action_button.set_label('Update') 256 | self.secondary_action_button.set_css_classes(['suggested-action']) 257 | self.secondary_action_button.set_visible(True) 258 | 259 | self.primary_action_button.set_label('Uninstall') 260 | self.primary_action_button.set_css_classes(['destructive-action']) 261 | 262 | elif self.app_list_element.installed_status == InstalledStatus.UPDATING: 263 | self.primary_action_button.set_label('Updating') 264 | 265 | elif self.app_list_element.installed_status == InstalledStatus.ERROR: 266 | self.primary_action_button.set_label('Error') 267 | self.primary_action_button.set_css_classes(['destructive-action']) 268 | 269 | # Loads the description text from external sources, like an HTTP request 270 | @_async 271 | def load_description(self): 272 | try: 273 | desc = self.provider.get_long_description(self.app_list_element) 274 | except Exception as e: 275 | logging.error(e) 276 | desc = '' 277 | 278 | self.set_description(desc) 279 | 280 | @idle 281 | def set_description(self, desc): 282 | self.show_row_spinner(False) 283 | self.description.set_markup(desc) 284 | 285 | def on_source_selector_changed(self, widget): 286 | new_source_id = widget.get_active_id() 287 | 288 | if not new_source_id: 289 | return 290 | 291 | sources = [self.app_list_element, *self.alt_sources] 292 | sel_source = self.provider.get_selected_source(sources, new_source_id) 293 | sources.remove(sel_source) 294 | 295 | self.set_app_list_element( 296 | sel_source, 297 | load_icon_from_network=True, 298 | alt_sources=sources 299 | ) 300 | 301 | def provider_refresh_installed_status(self, status: Optional[InstalledStatus] = None, final=False): 302 | if status: 303 | self.app_list_element.installed_status = status 304 | 305 | self.update_installation_status() 306 | 307 | if final: 308 | self.emit('refresh-updatable') 309 | 310 | # Load the preview images 311 | def load_previews(self): 312 | self.show_row_spinner(True) 313 | 314 | if self.previews_row.get_first_child(): 315 | self.previews_row.remove(self.previews_row.get_first_child()) 316 | 317 | carousel_row = Gtk.Box( 318 | orientation=Gtk.Orientation.VERTICAL, 319 | spacing=10, 320 | margin_top=20, 321 | ) 322 | 323 | carousel = Adw.Carousel(hexpand=True, spacing=10, allow_scroll_wheel=False) 324 | carousel_indicator = Adw.CarouselIndicatorDots(carousel=carousel) 325 | for widget in self.provider.get_previews(self.app_list_element): 326 | carousel.append(widget) 327 | 328 | carousel_row.append(carousel) 329 | carousel_row.append(carousel_indicator) 330 | 331 | carousel_row_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN) 332 | carousel_row_revealer.set_child(carousel_row) 333 | 334 | self.previews_row.append(carousel_row_revealer) 335 | carousel_row_revealer.set_reveal_child(True) 336 | 337 | self.show_row_spinner(False) 338 | 339 | # Load the boxed list with additional information 340 | def load_extra_details(self): 341 | gtk_list = Gtk.ListBox(css_classes=['boxed-list'], margin_bottom=20) 342 | 343 | row = Adw.ActionRow(title=self.provider.name.capitalize(), subtitle='Package type') 344 | logging.info(self.provider.icon) 345 | row_img = Gtk.Image(resource=self.provider.icon, pixel_size=34) 346 | row.add_prefix(row_img) 347 | gtk_list.append(row) 348 | 349 | if (self.app_list_element.installed_status == InstalledStatus.INSTALLED): 350 | row = Adw.ActionRow() 351 | row.set_title('Source') 352 | row.add_prefix(Gtk.Image(icon_name='window-pop-out-symbolic', pixel_size=34)) 353 | row.set_subtitle(self.provider.get_installed_from_source(self.app_list_element)) 354 | gtk_list.append(row) 355 | 356 | if 'file_path' in self.app_list_element.extra_data: 357 | row = Adw.ActionRow() 358 | row.set_title("File path") 359 | row.set_subtitle(self.app_list_element.extra_data['file_path']) 360 | gtk_list.append(row) 361 | 362 | if (self.app_list_element.installed_status != InstalledStatus.INSTALLED): 363 | row = Adw.ActionRow() 364 | row.set_title('Available from:') 365 | for r in self.provider.get_available_from_labels(self.app_list_element): 366 | row.set_subtitle(r) 367 | 368 | gtk_list.append(row) 369 | 370 | self.extra_data.append(gtk_list) 371 | 372 | def show_row_spinner(self, status: bool): 373 | self.desc_row_spinner.set_visible(status) 374 | self.desc_row_spinner.set_spinning(status) 375 | -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/apps/it.mijorus.boutique.Devel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | -------------------------------------------------------------------------------- /src/providers/AppImageProvider.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import threading 4 | import logging 5 | import urllib 6 | import re 7 | import os 8 | import shutil 9 | import time 10 | import hashlib 11 | import html2text 12 | import subprocess 13 | import filecmp 14 | from xdg import DesktopEntry 15 | 16 | from ..lib import flatpak, terminal 17 | from ..models.AppListElement import AppListElement, InstalledStatus 18 | from ..lib.async_utils import _async 19 | from ..lib.utils import log, cleanhtml, key_in_dict, gtk_image_from_url, qq, get_application_window, get_giofile_content_type, get_gsettings, create_dict, gio_copy, get_file_hash 20 | from ..components.CustomComponents import LabelStart 21 | from ..models.Provider import Provider 22 | from ..models.Models import FlatpakHistoryElement, AppUpdateElement 23 | from typing import List, Callable, Union, Dict, Optional, List, TypedDict 24 | from gi.repository import GLib, Gtk, Gdk, GdkPixbuf, Gio, GObject, Pango, Adw 25 | 26 | 27 | class ExtractedAppImage(): 28 | desktop_entry: Optional[DesktopEntry.DesktopEntry] 29 | extraction_folder: Optional[Gio.File] 30 | container_folder: Gio.File 31 | appimage_file: Gio.File 32 | desktop_file: Optional[Gio.File] 33 | icon_file: Optional[Gio.File] 34 | 35 | 36 | class AppImageListElement(AppListElement): 37 | def __init__(self, file_path: str, desktop_entry: Optional[DesktopEntry.DesktopEntry], icon: Optional[str], **kwargs): 38 | super().__init__(**kwargs) 39 | self.file_path = file_path 40 | self.desktop_entry = desktop_entry 41 | self.icon = icon 42 | 43 | 44 | class AppImageProvider(Provider): 45 | def __init__(self): 46 | self.name = 'appimage' 47 | self.icon = "/it/mijorus/boutique/assets/App-image-logo.png" 48 | self.small_icon = "/it/mijorus/boutique/assets/appimage-showcase.png" 49 | logging.info(f'Activating {self.name} provider') 50 | 51 | self.general_messages = [] 52 | self.update_messages = [] 53 | 54 | self.modal_gfile: Optional[Gio.File] = None 55 | self.modal_gfile_createshortcut_check: Optional[Gtk.CheckButton] = None 56 | 57 | def list_installed(self) -> List[AppListElement]: 58 | default_folder_path = self.get_appimages_default_destination_path() 59 | output = [] 60 | 61 | try: 62 | folder = Gio.File.new_for_path(default_folder_path) 63 | desktop_files_dir = f'{GLib.get_user_data_dir()}/applications/' 64 | 65 | for file_name in os.listdir(desktop_files_dir): 66 | gfile = Gio.File.new_for_path(desktop_files_dir + f'/{file_name}') 67 | 68 | try: 69 | if get_giofile_content_type(gfile) == 'application/x-desktop': 70 | 71 | entry = DesktopEntry.DesktopEntry(filename=gfile.get_path()) 72 | if entry.getExec().startswith(default_folder_path) and GLib.file_test(entry.getExec(), GLib.FileTest.EXISTS): 73 | list_element = AppImageListElement( 74 | name=entry.getName(), 75 | description=entry.getComment(), 76 | icon=entry.getIcon(), 77 | app_id='', 78 | version=entry.get('X-AppImage-Version'), 79 | installed_status=InstalledStatus.INSTALLED, 80 | file_path=entry.getExec(), 81 | provider=self.name, 82 | desktop_entry=entry 83 | ) 84 | 85 | output.append(list_element) 86 | 87 | except Exception as e: 88 | logging.warn(e) 89 | 90 | except Exception as e: 91 | logging.error(e) 92 | 93 | return output 94 | 95 | def is_installed(self, el: AppImageListElement, alt_sources: list[AppListElement] = []) -> tuple[bool, Optional[AppListElement]]: 96 | if el.file_path: 97 | for file_name in os.listdir(self.get_appimages_default_destination_path()): 98 | installed_gfile = Gio.File.new_for_path(self.get_appimages_default_destination_path() + '/' + file_name) 99 | loaded_gfile = Gio.File.new_for_path(el.file_path) 100 | 101 | if get_giofile_content_type(installed_gfile) == 'application/vnd.appimage': 102 | if filecmp.cmp(installed_gfile.get_path(), loaded_gfile.get_path(), shallow=False): 103 | el.file_path = installed_gfile.get_path() 104 | return True, None 105 | 106 | return False, None 107 | 108 | def get_icon(self, el: AppImageListElement, repo: str = None, load_from_network: bool = False) -> Gtk.Image: 109 | icon_path = None 110 | 111 | # if hasattr(el, name): 112 | # icon_path = el.extra_data['tmp_icon'].get_path() 113 | if el.desktop_entry: 114 | icon_path = el.desktop_entry.getIcon() 115 | 116 | if icon_path and os.path.exists(icon_path): 117 | return Gtk.Image.new_from_file(icon_path) 118 | else: 119 | icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) 120 | 121 | if el.desktop_entry and icon_theme.has_icon(el.desktop_entry.getIcon()): 122 | return Gtk.Image.new_from_icon_name(el.desktop_entry.getIcon()) 123 | 124 | return Gtk.Image(icon_name='application-x-executable-symbolic') 125 | 126 | def uninstall(self, el: AppImageListElement, callback: Callable[[bool], None]): 127 | try: 128 | os.remove(el.file_path) 129 | os.remove(el.desktop_entry.getFileName()) 130 | el.set_installed_status(InstalledStatus.NOT_INSTALLED) 131 | 132 | callback(True) 133 | except Exception as e: 134 | logging.error(e) 135 | callback(False) 136 | 137 | def install(self, el: AppListElement, c: Callable[[bool], None]): 138 | pass 139 | 140 | def search(self, query: str) -> List[AppListElement]: 141 | return [] 142 | 143 | def get_long_description(self, el: AppListElement) -> str: 144 | return '' 145 | 146 | def load_extra_data_in_appdetails(self, widget: Gtk.Widget, list_element: AppListElement): 147 | pass 148 | 149 | def list_updatables(self) -> List[AppUpdateElement]: 150 | return [] 151 | 152 | def update(self, el: AppListElement, callback: Callable[[bool], None]): 153 | pass 154 | 155 | def update_all(self, callback: Callable[[bool, str, bool], None]): 156 | pass 157 | 158 | def updates_need_refresh(self) -> bool: 159 | return False 160 | 161 | def run(self, el: AppImageListElement): 162 | if self.get_appimages_default_destination_path() in el.file_path: 163 | terminal.threaded_sh([f'{el.file_path}']) 164 | 165 | def can_install_file(self, file: Gio.File) -> bool: 166 | return get_giofile_content_type(file) in ['application/vnd.appimage', 'application/x-iso9660-appimage'] 167 | 168 | def is_updatable(self, app_id: str) -> bool: 169 | return False 170 | 171 | @_async 172 | def install_file(self, list_element: AppImageListElement, callback: Callable[[bool], None]) -> bool: 173 | logging.info('Installing appimage: ' + list_element.file_path) 174 | list_element.installed_status = InstalledStatus.INSTALLING 175 | extracted_appimage = None 176 | 177 | try: 178 | extracted_appimage = self.extract_appimage(file_path=list_element.file_path) 179 | dest_file_info = extracted_appimage.appimage_file.query_info('*', Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS) 180 | 181 | if extracted_appimage.extraction_folder.query_exists(): 182 | # Move .appimage to its default location 183 | appimages_destination_path = self.get_appimages_default_destination_path() 184 | 185 | # how the appimage will be called 186 | safe_app_name = f'boutique_{dest_file_info.get_name()}' 187 | if extracted_appimage.desktop_entry: 188 | safe_app_name = f'{terminal.sanitize(extracted_appimage.desktop_entry.getName())}_{dest_file_info.get_name()}' 189 | 190 | dest_appimage_file = Gio.File.new_for_path(appimages_destination_path + '/' + safe_app_name + '.appimage') 191 | 192 | if gio_copy(extracted_appimage.appimage_file, dest_appimage_file): 193 | log(f'file copied to {appimages_destination_path}') 194 | 195 | os.chmod(dest_appimage_file.get_path(), 0o755) 196 | list_element.file_path = dest_appimage_file.get_path() 197 | 198 | # copy the icon file 199 | icon_file = None 200 | dest_appimage_icon_file = None 201 | if extracted_appimage.desktop_entry: 202 | icon_file = extracted_appimage.icon_file 203 | 204 | if icon_file and os.path.exists(icon_file.get_path()): 205 | if not os.path.exists(f'{appimages_destination_path}/.icons'): 206 | os.mkdir(f'{appimages_destination_path}/.icons') 207 | 208 | dest_appimage_icon_file = Gio.File.new_for_path(f'{appimages_destination_path}/.icons/{safe_app_name}') 209 | gio_copy(icon_file, dest_appimage_icon_file) 210 | 211 | # Move .desktop file to its default location 212 | dest_destop_file_path = f'{GLib.get_user_data_dir()}/applications/{safe_app_name}.desktop' 213 | dest_destop_file_path = dest_destop_file_path.replace(' ', '_') 214 | 215 | with open(extracted_appimage.desktop_file.get_path(), 'r') as desktop_file_python: 216 | desktop_file_content = desktop_file_python.read() 217 | 218 | desktop_file_content = re.sub( 219 | r'Exec=.*$', 220 | f"Exec={dest_appimage_file.get_path()}", 221 | desktop_file_content, 222 | flags=re.MULTILINE 223 | ) 224 | 225 | desktop_file_content = re.sub( 226 | r'Icon=.*$', 227 | f"Icon={dest_appimage_icon_file.get_path() if dest_appimage_icon_file else 'applications-other'}", 228 | desktop_file_content, 229 | flags=re.MULTILINE 230 | ) 231 | 232 | final_app_name = extracted_appimage.appimage_file.get_basename() 233 | if extracted_appimage.desktop_entry: 234 | final_app_name = f"{extracted_appimage.desktop_entry.getName()}" 235 | if extracted_appimage.desktop_entry.get('X-AppImage-Version'): 236 | final_app_name += f' ({extracted_appimage.desktop_entry.get("X-AppImage-Version")})' 237 | 238 | desktop_file_content = re.sub( 239 | r'Name=.*$', 240 | f"Name={final_app_name}", 241 | desktop_file_content, 242 | flags=re.MULTILINE 243 | ) 244 | 245 | with open(dest_destop_file_path, 'w+') as desktop_file_python_dest: 246 | desktop_file_python_dest.write(desktop_file_content) 247 | 248 | if os.path.exists(dest_destop_file_path): 249 | list_element.extra_data['desktop_entry'] = DesktopEntry.DesktopEntry(filename=dest_destop_file_path) 250 | list_element.installed_status = InstalledStatus.INSTALLED 251 | else: 252 | logging.info('errore') 253 | raise Exception('Extraction folder does not exists') 254 | 255 | except Exception as e: 256 | logging.error('Appimage installation error: ' + e) 257 | 258 | try: 259 | self.post_file_extraction_cleanup(extracted_appimage) 260 | except Exception as g: 261 | pass 262 | 263 | list_element.installed_status = InstalledStatus.ERROR 264 | 265 | terminal.sh(['update-desktop-database']) 266 | callback(list_element.installed_status == InstalledStatus.INSTALLED) 267 | return True 268 | 269 | def create_list_element_from_file(self, file: Gio.File) -> AppListElement: 270 | app_name: str = file.get_parse_name().split('/')[-1] 271 | 272 | return AppImageListElement( 273 | name=app_name, 274 | description='', 275 | app_id=hashlib.md5(open(file.get_path(), 'rb').read()).hexdigest(), 276 | provider=self.name, 277 | installed_status=InstalledStatus.NOT_INSTALLED, 278 | file_path=file.get_path(), 279 | desktop_entry=None, 280 | icon=None 281 | ) 282 | 283 | def open_file_dialog(self, file: Gio.File, parent: Gtk.Widget): 284 | self.modal_gfile = file 285 | app_name: str = file.get_parse_name().split('/')[-1] 286 | modal_text = f"You are trying to open the following AppImage: \n\n📦️ {app_name}" 287 | modal_text += '\n\nAppImages are self-contained applications that\ncan be executed without requiring installation' 288 | modal_text += '\n\nYou can decide to execute this app immediately\nor create a desktop shortcut for faster access.\n' 289 | 290 | extra_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 291 | modal_text_label = Gtk.Label() 292 | modal_text_label.set_markup(modal_text) 293 | extra_content.append(modal_text_label) 294 | 295 | self.modal_gfile_createshortcut_check = Gtk.CheckButton(label='Create a desktop shortcut') 296 | extra_content.append(self.modal_gfile_createshortcut_check) 297 | 298 | self.open_file_options_dialog = Adw.MessageDialog( 299 | heading='Opening sideloaded AppImage', 300 | body='', 301 | extra_child=extra_content 302 | ) 303 | 304 | self.open_file_options_dialog.add_response('cancel', 'Cancel') 305 | self.open_file_options_dialog.add_response('run', 'Run') 306 | self.open_file_options_dialog.set_response_appearance('cancel', Adw.ResponseAppearance.DESTRUCTIVE) 307 | self.open_file_options_dialog.set_response_appearance('run', Adw.ResponseAppearance.SUGGESTED) 308 | 309 | self.open_file_options_dialog.connect('response', self.on_file_dialog_run_option_selected) 310 | self.open_file_options_dialog.set_transient_for(parent) 311 | return self.open_file_options_dialog 312 | 313 | def on_file_dialog_run_option_selected(self, widget: Adw.MessageDialog, user_response: str): 314 | if user_response == 'run' and self.modal_gfile: 315 | logging.info('Running appimage: ' + self.modal_gfile.get_path()) 316 | os.chmod(self.modal_gfile.get_path(), 0o755) 317 | terminal.threaded_sh([self.modal_gfile.get_path()]) 318 | 319 | if self.modal_gfile_createshortcut_check and (self.modal_gfile_createshortcut_check.get_active()): 320 | l = AppListElement( 321 | name=self.modal_gfile.get_path(), 322 | description='', 323 | app_id=get_file_hash(self.modal_gfile), 324 | provider=self.name, 325 | installed_status=InstalledStatus.NOT_INSTALLED, 326 | file_path=self.modal_gfile.get_path() 327 | ) 328 | 329 | self.install_file(l, lambda x: None) 330 | 331 | self.modal_gfile_createshortcut_check = None 332 | self.modal_gfile = None 333 | self.open_file_options_dialog.close() 334 | 335 | def get_selected_source(self, list_element: list[AppListElement], source_id: str) -> AppListElement: 336 | pass 337 | 338 | def get_source_details(self, list_element: AppListElement) -> tuple[str, str]: 339 | pass 340 | 341 | def set_refresh_installed_status_callback(self, callback: Optional[Callable]): 342 | pass 343 | 344 | def post_file_extraction_cleanup(self, extraction: ExtractedAppImage): 345 | print(extraction.container_folder.get_path()) 346 | if extraction.container_folder.query_exists(): 347 | shutil.rmtree(extraction.container_folder.get_path()) 348 | 349 | def extract_appimage(self, file_path: str) -> ExtractedAppImage: 350 | file = Gio.File.new_for_path(file_path) 351 | 352 | if get_giofile_content_type(file) in ['application/x-iso9660-appimage']: 353 | raise Exception('This file format cannot be extracted!') 354 | 355 | icon_file: Optional[Gio.File] = None 356 | desktop_file: Optional[Gio.File] = None 357 | 358 | desktop_entry: Optional[DesktopEntry.DesktopEntry] = None 359 | extraction_folder = None 360 | 361 | temp_file = None 362 | 363 | # hash file 364 | temp_file = 'boutique_appimage_' + get_file_hash(file) 365 | folder = Gio.File.new_for_path(GLib.get_tmp_dir() + f'/it.mijorus.boutique/appimages/{temp_file}') 366 | 367 | if folder.query_exists(): 368 | shutil.rmtree(folder.get_path()) 369 | 370 | if folder.make_directory_with_parents(None): 371 | dest_file = Gio.File.new_for_path(folder.get_path() + f'/{temp_file}') 372 | file_copy = file.copy( 373 | dest_file, 374 | Gio.FileCopyFlags.OVERWRITE, 375 | None, None, None, None 376 | ) 377 | 378 | dest_file_info = dest_file.query_info('*', Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS) 379 | 380 | if file_copy: 381 | squash_folder = Gio.File.new_for_path(f'{folder.get_path()}/squashfs-root') 382 | 383 | # set exec permission for dest_file 384 | os.chmod(dest_file.get_path(), 0o755) 385 | logging.info('Appimage, extracting ' + file_path) 386 | terminal.sh(["bash", "-c", f"cd {folder.get_path()} && {dest_file.get_path()} --appimage-extract"]) 387 | 388 | if squash_folder.query_exists(): 389 | extraction_folder = squash_folder 390 | 391 | desktop_files: list[str] = filter(lambda x: x.endswith('.desktop'), os.listdir(f'{folder.get_path()}/squashfs-root')) 392 | 393 | for d in desktop_files: 394 | gdesk_file = Gio.File.new_for_path(f'{folder.get_path()}/squashfs-root/{d}') 395 | if get_giofile_content_type(gdesk_file) == 'application/x-desktop': 396 | desktop_file = gdesk_file 397 | break 398 | 399 | if desktop_file: 400 | desktop_entry = DesktopEntry.DesktopEntry(desktop_file.get_path()) 401 | 402 | if desktop_entry.getIcon(): 403 | # https://github.com/AppImage/AppImageSpec/blob/master/draft.md#the-filesystem-image 404 | for icon_xt in ['.png', '.svgz', '.svg']: 405 | icon_xt_f = Gio.File.new_for_path(extraction_folder.get_path() + f'/{desktop_entry.getIcon()}{icon_xt}') 406 | 407 | if icon_xt_f.query_exists(): 408 | icon_file = icon_xt_f 409 | break 410 | 411 | result = ExtractedAppImage() 412 | result.desktop_entry = desktop_entry 413 | result.extraction_folder = extraction_folder 414 | result.container_folder = folder 415 | result.appimage_file = dest_file 416 | result.desktop_file = desktop_file 417 | result.icon_file = icon_file 418 | 419 | return result 420 | 421 | def get_appimages_default_destination_path(self) -> str: 422 | return get_gsettings().get_string('appimages-default-folder').replace('~', GLib.get_home_dir()) 423 | 424 | def get_previews(self, el): 425 | return [] 426 | 427 | def get_available_from_labels(self, el): 428 | return 'Local file' 429 | 430 | def get_installed_from_source(self, el): 431 | return 'Local file' 432 | -------------------------------------------------------------------------------- /src/assets/flathub-badge-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 23 | 49 | 54 | 55 | 57 | 58 | 60 | image/svg+xml 61 | 63 | 64 | 65 | 66 | 71 | 75 | 79 | 83 | 87 | 91 | 95 | 99 | 103 | 104 | 107 | 113 | 117 | 127 | 137 | 142 | 147 | 148 | 152 | 162 | 172 | 177 | 182 | 183 | 187 | 197 | 207 | 212 | 217 | 218 | 219 | 223 | 227 | 231 | 235 | 239 | 243 | 247 | 251 | 255 | 259 | 263 | 264 | 265 | 266 | -------------------------------------------------------------------------------- /src/providers/FlatpakProvider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import string 4 | import threading 5 | import urllib 6 | import re 7 | import requests 8 | import html2text 9 | import time 10 | import subprocess 11 | from typing import TypedDict 12 | from xdg import DesktopEntry 13 | 14 | from ..lib import flatpak, terminal 15 | from ..lib.async_utils import _async 16 | from ..lib.utils import log, cleanhtml, key_in_dict, gtk_image_from_url, qq, get_application_window, get_giofile_content_type 17 | from ..models.AppListElement import AppListElement, InstalledStatus 18 | from ..models.Models import ProviderMessage 19 | from ..components.CustomComponents import LabelStart 20 | from ..models.Provider import Provider 21 | from ..models.Models import FlatpakHistoryElement, AppUpdateElement 22 | from typing import List, Callable, Union, Dict, Optional, List 23 | from gi.repository import GLib, Gtk, Gdk, GdkPixbuf, Gio, GObject, Adw 24 | 25 | 26 | class FlatpakState(TypedDict): 27 | installed_status: InstalledStatus 28 | 29 | 30 | class FlatpakProvider(Provider): 31 | def __init__(self): 32 | 33 | self.name = 'flatpak' 34 | self.icon = "/it/mijorus/boutique/assets/flathub-badge-logo.svg" 35 | self.small_icon = "/it/mijorus/boutique/assets/Flatpak-Logo-showcase.png" 36 | logging.info('Activating ' + self.name + ' provider') 37 | 38 | self.refresh_installed_status_callback: Optional[Callable] = None 39 | self.remote_ls_updatable_cache: Optional[List] = None 40 | self.list_updatables_cache: Optional[str] = None 41 | self.update_section_cache = None 42 | self.list_installed_cache = None 43 | self.do_updates_need_refresh = True 44 | self.ignored_patterns = [ 45 | 'org.gtk.Gtk3theme', 46 | 'org.kde.PlatformTheme', 47 | 'org.kde.WaylandDecoration', 48 | 'org.kde.KStyle', 49 | 'org.videolan.VLC.Plugin' 50 | ] 51 | 52 | self.flatpaks_state: dict[str, FlatpakState] = {} 53 | self.list_installed_list: List[AppListElement] = [] 54 | 55 | self.general_messages = [] 56 | self.update_messages = [] 57 | self.modal_gfile = None 58 | 59 | def is_installed(self, list_element: AppListElement, alt_sources: list[AppListElement] = []): 60 | ref = self.get_ref(list_element) 61 | i = flatpak.is_installed(ref) 62 | 63 | if not i: 64 | return i, None 65 | 66 | installed_origin = flatpak.get_ref_origin(list_element.id) 67 | 68 | if (list_element.extra_data['origin'] != installed_origin): 69 | if (alt_sources): 70 | for alt in alt_sources: 71 | if alt.extra_data['origin'] == installed_origin: 72 | alt_origin_info = flatpak.get_info(ref) 73 | alt.extra_data['version'] = alt_origin_info['version'] 74 | return i, alt 75 | else: 76 | raise Exception('Missing origin ' + installed_origin) 77 | 78 | else: 79 | return i, None 80 | 81 | def list_installed(self) -> List[AppListElement]: 82 | output: list[AppListElement] = [] 83 | 84 | for app in flatpak.apps_list(): 85 | if app['application'] == 'it.mijorus.boutique': 86 | continue 87 | 88 | output.append( 89 | AppListElement( 90 | app['name'], 91 | app['description'], 92 | app['application'], 93 | 'flatpak', 94 | InstalledStatus.INSTALLED, 95 | 96 | ref=app['ref'], 97 | origin=app['origin'], 98 | arch=app['arch'], 99 | version=app['version'], 100 | ) 101 | ) 102 | 103 | return output 104 | 105 | def get_icon(self, list_element: AppListElement, repo='flathub', load_from_network: bool = False) -> Gtk.Image: 106 | pixel_size = 45 107 | image = Gtk.Image(resource=self.icon) 108 | 109 | if load_from_network: 110 | pref_remote = list_element.extra_data['remotes'][0] 111 | if ('flathub' in list_element.extra_data['remotes']): 112 | pref_remote = 'flathub' 113 | 114 | remotes = flatpak.remotes_list() 115 | pref_remote_data = key_in_dict(remotes, pref_remote) 116 | 117 | if pref_remote_data and ('url' in pref_remote_data): 118 | try: 119 | url = re.sub(r'\/$', '', pref_remote_data['url']) 120 | image = gtk_image_from_url(f'{url}/appstream/x86_64/icons/128x128/{urllib.parse.quote(list_element.id, safe="")}.png', image) 121 | image.set_pixel_size(pixel_size) 122 | return image 123 | 124 | except Exception as e: 125 | logging.warn(e) 126 | 127 | if ('origin' in list_element.extra_data) and ('arch' in list_element.extra_data): 128 | repo = list_element.extra_data['origin'] 129 | aarch = list_element.extra_data['arch'] 130 | local_file_path = f'{GLib.get_user_data_dir()}/flatpak/appstream/{repo}/{aarch}/active/icons/128x128/{list_element.id}.png' 131 | 132 | if GLib.file_test(local_file_path, GLib.FileTest.EXISTS): 133 | image = Gtk.Image.new_from_file(local_file_path) 134 | elif Gtk.IconTheme.get_for_display(Gdk.Display.get_default()).has_icon(list_element.id): 135 | image = Gtk.Image(icon_name=list_element.id) 136 | 137 | image.set_pixel_size(pixel_size) 138 | return image 139 | 140 | def uninstall(self, list_element: AppListElement, callback: Callable[[bool], None] = None): 141 | success = False 142 | 143 | def after_uninstall(_: bool): 144 | list_element.set_installed_status(InstalledStatus.NOT_INSTALLED) 145 | 146 | if callback: 147 | callback(_) 148 | 149 | try: 150 | flatpak.remove( 151 | self.get_ref(list_element), 152 | list_element.id, 153 | lambda _: after_uninstall(True) 154 | ) 155 | 156 | success = True 157 | except Exception as e: 158 | logging.error(e) 159 | after_uninstall(False) 160 | list_element.set_installed_status(InstalledStatus.ERROR) 161 | 162 | return success 163 | 164 | def install(self, list_element: AppListElement, callback: Callable[[bool], None] = None): 165 | def install_thread(list_element: AppListElement, callback: Callable): 166 | try: 167 | ref = self.get_ref(list_element) 168 | list_element.extra_data['ref'] = ref 169 | flatpak.install(list_element.extra_data['origin'], ref) 170 | list_element.set_installed_status(InstalledStatus.INSTALLED) 171 | 172 | if callback: 173 | callback(True) 174 | 175 | except Exception as e: 176 | logging.error(e) 177 | list_element.set_installed_status(InstalledStatus.ERROR) 178 | if callback: 179 | callback(False) 180 | 181 | if not 'origin' in list_element.extra_data: 182 | raise Exception('Missing "origin" in list_element') 183 | 184 | thread = threading.Thread(target=install_thread, args=(list_element, callback, ), daemon=True) 185 | thread.start() 186 | 187 | def search(self, query: str) -> List[AppListElement]: 188 | installed_apps = flatpak.apps_list() 189 | result = flatpak.search(query) 190 | 191 | output = [] 192 | 193 | apps: Dict[str, list] = {} 194 | for app in result[0:100]: 195 | skip = False 196 | for i in self.ignored_patterns: 197 | if i in app['application']: 198 | skip = True 199 | break 200 | 201 | if skip: 202 | continue 203 | 204 | if not app['application'] in apps: 205 | apps[app['application']] = [] 206 | 207 | apps[app['application']].append(app) 208 | 209 | for app_id, app_sources in apps.items(): 210 | installed_status = InstalledStatus.NOT_INSTALLED 211 | for i in installed_apps: 212 | if i['application'] == app_id: 213 | installed_status = InstalledStatus.INSTALLED 214 | break 215 | 216 | remotes_map: Dict[str, str] = {} 217 | fk_remotes = flatpak.remotes_list() 218 | 219 | app_list_element_sources: List[AppListElement] = [] 220 | preselected_app: Optional[AppListElement] = None 221 | 222 | for app_source in app_sources: 223 | branch_name = app_source["branch"] 224 | app_remotes = app_source['remotes'].split(',') 225 | 226 | for r in app_remotes: 227 | if (r in fk_remotes): 228 | fk_remote_title = fk_remotes[r]['title'] 229 | 230 | if len(app_sources) > 1: 231 | fk_remote_title += f' ({branch_name})' 232 | 233 | app_source['source_id'] = f'{r}:{app_source["application"]}/{flatpak.get_default_aarch()}/{app_source["branch"]}' 234 | remotes_map[app_source['source_id']] = fk_remote_title 235 | 236 | source_list_element = AppListElement( 237 | (app_source['name']), 238 | (app_source['description']), 239 | app_source['application'], 240 | 'flatpak', 241 | installed_status, 242 | None, 243 | 244 | version=app_source['version'], 245 | branch=app_source['branch'], 246 | origin=r, 247 | remote=r, 248 | source_id=app_source['source_id'], 249 | remotes=app_remotes 250 | ) 251 | 252 | output.append(source_list_element) 253 | 254 | return output 255 | 256 | def get_long_description(self, el: AppListElement) -> str: 257 | from_remote = key_in_dict(el.extra_data, 'origin') 258 | 259 | if ('remotes' in el.extra_data and 'flathub' in el.extra_data['remotes']): 260 | from_remote = 'flathub' 261 | 262 | appstream = flatpak.get_appstream(el.id, from_remote) 263 | 264 | output = '' 265 | if key_in_dict(appstream, 'description'): 266 | output = html2text.html2text(appstream['description']) 267 | 268 | return f'{el.description}\n\n{output}'.replace("&", "&") 269 | 270 | def get_available_from_labels(self, list_element): 271 | remotes = flatpak.remotes_list() 272 | element_remotes: List[str] = [] 273 | if 'remotes' in list_element.extra_data: 274 | element_remotes.extend(list_element.extra_data['remotes']) 275 | else: 276 | element_remotes.append(list_element.extra_data['origin']) 277 | 278 | out = [] 279 | 280 | for r in element_remotes: 281 | if (r in remotes): 282 | out.append(self.get_remote_link(r, list_element)) 283 | 284 | return out 285 | 286 | def get_installed_from_source(self, el): 287 | return el.extra_data['origin'].capitalize() 288 | 289 | def get_remote_link(self, r: str, el) -> str: 290 | remotes = flatpak.remotes_list() 291 | 292 | if (r in remotes): 293 | if 'homepage' in remotes[r]: 294 | remote_link = f'https://flathub.org/apps/details/{el.id}' if r == 'flathub' else remotes[r]['homepage'] 295 | return remote_link 296 | else: 297 | return remotes[r]['title'] 298 | 299 | return '' 300 | 301 | def load_extra_data_in_appdetails(self, widget, list_element: AppListElement): 302 | if 'origin' in list_element.extra_data: 303 | expander = Gtk.Expander(label="Show history", child=Gtk.Spinner()) 304 | expander.ref = self.get_ref(list_element) 305 | expander._app = list_element 306 | expander.remote = list_element.extra_data['origin'] 307 | expander.has_history = False 308 | expander.connect('notify::expanded', self.on_history_expanded) 309 | 310 | widget.append(expander) 311 | 312 | def get_ref(self, list_element: AppListElement): 313 | if 'ref' in list_element.extra_data: 314 | return list_element.extra_data['ref'] 315 | 316 | return f'{list_element.id}/{flatpak.get_default_aarch()}/{list_element.extra_data["branch"]}' 317 | 318 | def create_history_expander(self, history, expander, success: bool): 319 | if (not success): 320 | expander.set_label('Couldn\'t load data') 321 | return 322 | 323 | expander.set_label('History') 324 | 325 | list_box = Gtk.ListBox(css_classes=["boxed-list"], show_separators=False, margin_top=10) 326 | 327 | if history: 328 | for h in history: 329 | row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, margin_top=5, margin_bottom=5, margin_start=5, margin_end=5) 330 | col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 331 | 332 | title = Gtk.Label(label=h.date.split('+')[0], halign=Gtk.Align.START, css_classes=['heading'], wrap=True) 333 | subtitle = Gtk.Label(label=h.subject, halign=Gtk.Align.START, css_classes=['dim-label', 'caption'], selectable=True, wrap=True, max_width_chars=100) 334 | col.append(title) 335 | col.append(subtitle) 336 | row.append(col) 337 | 338 | col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, valign=Gtk.Align.CENTER, vexpand=True, hexpand=True, halign=Gtk.Align.END) 339 | install_label = qq(expander._app.installed_status == InstalledStatus.INSTALLED, 'Downgrade', 'Install') 340 | install_btn = Gtk.Button(label=install_label) 341 | install_btn.connect('clicked', self.show_downgrade_dialog, {'commit': h.commit, 'list_element': expander._app}) 342 | col.append(install_btn) 343 | row.append(col) 344 | 345 | list_box.append(row) 346 | 347 | else: 348 | list_box.append(Adw.ActionRow(title='No previous versions were found')) 349 | 350 | expander.has_history = True 351 | expander.set_child(list_box) 352 | 353 | def async_load_app_history(self, expander: Gtk.Expander): 354 | success = True 355 | 356 | try: 357 | history: List[FlatpakHistoryElement] = flatpak.get_app_history(expander.ref, expander.remote) 358 | except Exception as e: 359 | logging.error(e) 360 | success = False 361 | 362 | GLib.idle_add(self.create_history_expander, history, expander, success) 363 | 364 | def on_history_expanded(self, expander: Gtk.Expander, state): 365 | if expander.has_history: 366 | return 367 | 368 | expander.set_label('Loading history...') 369 | if isinstance(expander.get_child(), Gtk.Spinner): 370 | expander.get_child().set_spinning(True) 371 | 372 | threading.Thread(target=self.async_load_app_history, args=(expander, )).start() 373 | 374 | def list_updatables(self) -> List[AppUpdateElement]: 375 | # if not self.do_updates_need_refresh and (self.list_updatables_cache is not None): 376 | # update_output = self.list_updatables_cache 377 | # else: 378 | # self.update_remote_ls_updatable_cache() 379 | # update_output = terminal.sh(['flatpak', 'update', '--user'], return_stderr=True) 380 | # self.list_updatables_cache = update_output 381 | 382 | latest_remote_upstream = [] 383 | if self.list_updatables_cache == None: 384 | try: 385 | terminal.sh(['flatpak', 'update', '--appstream']) 386 | latest_remote_upstream = flatpak.remote_ls(updates_only=True, origin='flathub') 387 | except Exception as e: 388 | logging.error(e) 389 | self.update_messages.append(ProviderMessage('There was an error', 'warn')) 390 | 391 | self.list_updatables_cache = terminal.sh(['flatpak', 'update', '--user'], return_stderr=True) 392 | 393 | if (not self.list_updatables_cache) or (not '1.\t' in self.list_updatables_cache): 394 | return [] 395 | 396 | update_table_section = '' 397 | update_sections = self.list_updatables_cache.split('1.\t', maxsplit=1) 398 | 399 | if len(update_sections) > 1: 400 | update_table_section = '1.\t' + update_sections[1] 401 | update_table_section = update_table_section 402 | 403 | start_pattern = re.compile(r'^([0-9]+\.)') 404 | output = [] 405 | 406 | if update_table_section: 407 | for row in update_table_section.split('\n'): 408 | row = row.strip() 409 | if not re.match(start_pattern, row): 410 | break 411 | else: 412 | cols = [] 413 | for i, col in enumerate(row.split('\t')): 414 | col = col.strip() 415 | if (i > 0) and len(col) > 0: 416 | cols.append(col) 417 | 418 | update_size = ''.join(re.findall(r'([0-9]|,)', cols[4], flags=re.A)) if len(cols) > 3 else '0' 419 | app_update_element = AppUpdateElement(cols[0], update_size, None) 420 | output.append(app_update_element) 421 | 422 | for rc in latest_remote_upstream: 423 | if rc['application'] == app_update_element.id: 424 | app_update_element.to_version = rc['version'] 425 | break 426 | 427 | # self.do_updates_need_refresh = False 428 | return output 429 | 430 | def update(self, list_element: AppListElement, callback: Callable): 431 | self.list_updatables_cache = None 432 | 433 | def update_task(): 434 | ref = self.get_ref(list_element) 435 | success = False 436 | 437 | self.flatpaks_state[list_element.id] = {'installed_status': list_element.installed_status} 438 | 439 | try: 440 | terminal.sh(['flatpak', 'update', '--user', '--noninteractive', ref]) 441 | list_element.set_installed_status(InstalledStatus.INSTALLED) 442 | self.remote_ls_updatable_cache = None 443 | self.do_updates_need_refresh = True 444 | success = True 445 | except Exception as e: 446 | logging.error(e) 447 | list_element.set_installed_status(InstalledStatus.ERROR) 448 | 449 | self.flatpaks_state[list_element.id] = {'installed_status': list_element.installed_status} 450 | 451 | if self.refresh_installed_status_callback: 452 | self.refresh_installed_status_callback(final=True) 453 | 454 | if callback: 455 | callback(success) 456 | 457 | list_element.set_installed_status(InstalledStatus.UPDATING) 458 | threading.Thread(target=update_task, daemon=True).start() 459 | 460 | def run(self, el: AppListElement): 461 | terminal.threaded_sh(['flatpak', 'run', '--user', el.id]) 462 | 463 | def update_all(self, callback: Callable): 464 | def update_task(callback): 465 | success = False 466 | 467 | try: 468 | terminal.sh(['flatpak', 'update', '--user', '-y', '--noninteractive']) 469 | self.do_updates_need_refresh = True 470 | success = True 471 | except Exception as e: 472 | log(e) 473 | 474 | if callback: 475 | callback(success, 'flatpak') 476 | 477 | threading.Thread(target=update_task, daemon=True, args=(callback, )).start() 478 | 479 | def can_install_file(self, file: Gio.File): 480 | return get_giofile_content_type(file) in ['application/vnd.flatpak.ref'] 481 | 482 | def install_file(self, file, callback): 483 | def install_ref(path): 484 | log('installing ', path) 485 | terminal.sh(['flatpak', 'install', '--from', path, '--noninteractive', '--user']) 486 | if callback: 487 | callback(True) 488 | log('Installed!') 489 | 490 | threading.Thread(target=install_ref, args=(file.get_path(), ), daemon=True).start() 491 | 492 | def create_list_element_from_file(self, file: Gio.File) -> AppListElement: 493 | res = file.load_contents(None) 494 | contents: str = res.contents.decode('utf-8') 495 | 496 | props: Dict[str, str] = {} 497 | for line in contents.split('\n'): 498 | if not '=' in line: 499 | continue 500 | 501 | keyval = line.split('=', maxsplit=1) 502 | props[keyval[0].lower()] = keyval[1] 503 | 504 | # @todo 505 | installed_status = InstalledStatus.INSTALLED if flatpak.is_installed(props['name']) else InstalledStatus.NOT_INSTALLED 506 | 507 | name = props['name'] 508 | desc = props['title'] 509 | 510 | if props['url'] == flatpak.FLATHUB_REPO_URL: 511 | try: 512 | appstream = flatpak.get_appstream(name, 'flathub') 513 | if 'name' in appstream: 514 | name = appstream['name'] 515 | except: 516 | pass 517 | 518 | list_element = AppListElement(name, desc, props['name'], 'flatpak', installed_status, 519 | remotes=[flatpak.find_remote_from_url(props['url'])], 520 | branch=props['branch'], 521 | origin=flatpak.find_remote_from_url(props['url']), 522 | file_path=file.get_path() 523 | ) 524 | 525 | return list_element 526 | 527 | def show_downgrade_dialog(self, button: Gtk.Button, data: dict): 528 | list_element: AppListElement = data['list_element'] 529 | 530 | def install_old_version(): 531 | terminal.sh(['flatpak', 'kill', list_element.id], return_stderr=True) 532 | self.refresh_installed_status_callback(status=InstalledStatus.UPDATING) 533 | 534 | try: 535 | terminal.sh(['flatpak', 'update', f'--commit={data["commit"]}', '-y', '--noninteractive', list_element.id]) 536 | self.do_updates_need_refresh = True 537 | self.refresh_installed_status_callback(status=InstalledStatus.INSTALLED) 538 | except Exception as e: 539 | logging.error(e) 540 | self.refresh_installed_status_callback(final=True, status=InstalledStatus.ERROR) 541 | 542 | def on_downgrade_dialog_response(dialog: Gtk.Dialog, response: int): 543 | if response == Gtk.ResponseType.YES: 544 | threading.Thread(target=install_old_version, daemon=True).start() 545 | 546 | dialog.destroy() 547 | 548 | action = qq(data['list_element'].installed_status.INSTALLED, 'downgrade', 'install') 549 | self.downgrade_dialog = Gtk.MessageDialog( 550 | # flags=Gtk.DialogFlags.MODAL, 551 | message_type=Gtk.MessageType.QUESTION, 552 | buttons=Gtk.ButtonsType.YES_NO, 553 | text=f'Do you really want to {action} "{data["list_element"].name}" ?', 554 | transient_for=get_application_window(), 555 | secondary_text=f'An older version might contain bugs and could have issues with newer configuration files.' 556 | ) 557 | 558 | self.downgrade_dialog.connect('response', on_downgrade_dialog_response) 559 | self.downgrade_dialog.show() 560 | 561 | def get_selected_source(self, list_elements: list[AppListElement], source_id: str) -> AppListElement: 562 | for alt_source in list_elements: 563 | if source_id == self.create_source_id(alt_source): 564 | remote_ls_items = flatpak.remote_ls(updates_only=False, cached=True, origin=alt_source.extra_data['origin']) 565 | 566 | for rc in remote_ls_items: 567 | if (rc['application'] == alt_source.id) and (rc['origin'] == alt_source.extra_data['origin']): 568 | alt_source.extra_data['version'] = rc['version'] 569 | break 570 | 571 | return alt_source 572 | 573 | raise Exception('Missing list_element source!') 574 | 575 | def is_updatable(self, app_id: str) -> bool: 576 | if self.update_section_cache == None: 577 | update_output = terminal.sh(['flatpak', 'update', '--user'], return_stderr=True) 578 | self.refresh_update_section_cache(update_output) 579 | 580 | return (app_id in self.update_section_cache) 581 | 582 | def refresh_update_section_cache(self, update_output: Optional[str]): 583 | self.update_section_cache = '' 584 | 585 | if update_output: 586 | update_sections = update_output.split('1.\t', maxsplit=1) 587 | 588 | if len(update_sections) > 1: 589 | update_section = '1.\t' + update_sections[1] 590 | self.update_section_cache = update_section 591 | 592 | def get_source_details(self, list_element: AppListElement): 593 | return ( 594 | self.create_source_id(list_element), 595 | f"{list_element.extra_data['origin']} ({list_element.extra_data['branch']})" 596 | ) 597 | 598 | def create_source_id(self, list_element: AppListElement) -> str: 599 | return f"{list_element.extra_data['origin']}/{list_element.extra_data['branch']}" 600 | 601 | def set_refresh_installed_status_callback(self, callback: Optional[Callable]): 602 | self.refresh_installed_status_callback = callback 603 | 604 | def updates_need_refresh(self) -> bool: 605 | return self.do_updates_need_refresh 606 | 607 | def open_file_dialog(self, file: Gio.File, parent: Gtk.Widget): 608 | self.modal_gfile = file 609 | 610 | extra_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 611 | spinner = Gtk.Spinner(spinning=True) 612 | extra_content.append(spinner) 613 | 614 | self.open_file_options_dialog = Adw.MessageDialog( 615 | heading='Loading sideloaded Flatpak', 616 | body='', 617 | extra_child=extra_content 618 | ) 619 | 620 | self.load_file_dialog_information(extra_content, spinner) 621 | self.open_file_options_dialog.set_transient_for(parent) 622 | return self.open_file_options_dialog 623 | 624 | @_async 625 | def load_file_dialog_information(self, target_widget: Gtk.Widget, placeholder_widget: Gtk.Widget): 626 | def update_modal_content(): 627 | with open(self.modal_gfile.get_path(), 'r') as f: 628 | content = f.read() 629 | if 'IsRuntime=false' in content: 630 | app_name: str = self.modal_gfile.get_parse_name().split('/')[-1] 631 | modal_text = f"You are opening the following Flatpak: \n\n📦️ {app_name}" 632 | 633 | for line in content.split('\n'): 634 | if line.startswith('Url='): 635 | modal_text += f'\n\nFrom the following repository:\n\n🌐 {line.replace("Url=", "")}' 636 | 637 | modal_text_label = Gtk.Label() 638 | modal_text_label.set_markup(modal_text) 639 | target_widget.append(modal_text_label) 640 | 641 | for action in [['cancel', 'Cancel', Adw.ResponseAppearance.DESTRUCTIVE], ['install', 'Install', Adw.ResponseAppearance.SUGGESTED]]: 642 | self.open_file_options_dialog.add_response(action[0], action[1]) 643 | self.open_file_options_dialog.set_response_appearance(action[0], action[2]) 644 | 645 | self.open_file_options_dialog.connect('response', self.on_file_dialog_option_selected) 646 | target_widget.remove(placeholder_widget) 647 | 648 | GLib.idle_add(update_modal_content) 649 | 650 | def on_file_dialog_option_selected(self): 651 | return 652 | 653 | def get_previews(self, el): 654 | return [] 655 | 656 | # def get_previews(self, el: AppListElement) -> list[Gtk.Widget]: 657 | # def load_preview_image(url, image: Gtk.Image, button: Gtk.Button): 658 | # gtk_image_from_url(screenshot_sizes[selected_size], image) 659 | # button.set_visible(True) 660 | 661 | # if el.extra_data['origin'] == 'flathub': 662 | # appstream = flatpak.get_appstream(el.id, 'flathub') 663 | 664 | # output = [] 665 | # if appstream and ('screenshots' in appstream): 666 | # for screenshot_sizes in appstream['screenshots']: 667 | # selected_size = list(screenshot_sizes.keys())[0] 668 | # preview = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 669 | 670 | # image = Gtk.Image(pixel_size=400) 671 | # image_button = Gtk.Button(label='Open in the browser', visible=False, halign=Gtk.Align.CENTER) 672 | # image_button.connect('clicked', lambda w: Gtk.show_uri(None, screenshot_sizes[selected_size], time.time())) 673 | 674 | # preview.append(image) 675 | # preview.append(image_button) 676 | 677 | # output.append(preview) 678 | 679 | # threading.Thread(target=load_preview_image, daemon=True, args=(screenshot_sizes[selected_size], image, image_button)).start() 680 | 681 | # return output 682 | 683 | # return [] 684 | --------------------------------------------------------------------------------