├── 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 |
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 |
23 |
--------------------------------------------------------------------------------
/data/icons/hicolor/scalable/actions/funnel-outline-symbolic.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
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 |
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 |
5 | 600
6 | 300
7 |
8 |
16 |
17 |
18 |
19 | Hello, World!
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
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 |
18 |
--------------------------------------------------------------------------------
/data/icons/hicolor/scalable/actions/globe-symbolic.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
131 |
--------------------------------------------------------------------------------
/src/assets/App-image-logo-bw.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
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 |
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 |
--------------------------------------------------------------------------------