diff --git a/README.md b/README.md index 3b11d6a..9bc50fe 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,23 @@ ![Image banner in the style of GNOME art showing a box truck, tower crane, and storage garage sat on and next to a road.](readme_banner.svg) -## Warehouse is a versatile toolbox for managing flatpak user data, viewing flatpak app info, and batch managing installed flatpaks. +## Warehouse is a versatile toolbox and provides a simple UI to control complex Flatpak options, all without resorting to the command line. ## 🚀 Main Features: -1. **Viewing Flatpak Info:** 📋 Warehouse can display all the information provided by the `flatpak list` command in a user-friendly graphical window. Each item includes a button for easy copying. +1. **Viewing Flatpak Info:** 📋 Warehouse can display all the information provided by the `Flatpak list` command in a user-friendly graphical window. Each item includes a button for easy copying. -2. **Managing User Data:** đŸ—‘ī¸ Flatpaks store user data in a specific system location, often left behind when an app is uninstalled. Warehouse can uninstall an app and delete its data, delete data without uninstalling, or simply show if an app has user data. +2. **Change Package Versions:** â†•ī¸ Rollback any unwanted updates of any package, so long as the remote has older versions. -3. **Batch Actions:** ⚡ Warehouse features a batch mode for swift uninstallations, user data deletions, and app ID copying in bulk. +3. **Managing User Data:** đŸ—‘ī¸ Flatpaks store user data in a specific system location, often left behind when an app is uninstalled. Warehouse can uninstall an app and delete its data, delete data without uninstalling, or simply show if an app has user data. -4. **Leftover Data Management:** 📁 Warehouse scans the user data folder to check for installed apps associated with the data. If none are found, it can delete the data or attempt to install a matching flatpak. +4. **Batch Actions:** ⚡ Warehouse features a batch mode for swift uninstallations, user data deletions, and app ID copying in bulk. -5. **Manage Remotes:** đŸ“Ļ Installed and enabled Flatpak remotes can be deleted, and new remotes can be added. +5. **Leftover Data Management:** 📁 Warehouse scans the user data folder to check for installed apps associated with the data. If none are found, it can delete the data or attempt to install a matching Flatpak. -![Various screenshots of Warehouse's abilities](screenshots.png) +6. **Manage Remotes:** đŸ“Ļ Installed and enabled Flatpak remotes can be deleted, and new remotes can be added. + +7. **Make Snapshots:** 🕐 Copy app user data to take quick backups before doing anything risky with your data. ## âŦ Installation: @@ -39,13 +41,13 @@ Warehouse is now available on Flathub! Visit your software store and search for - The Warehouse project follows the [GNOME Code of Conduct](https://conduct.gnome.org/). See `CODE_OF_CONDUCT.md` for more information. ## â„šī¸ Important Notes: -- Warehouse assumes flatpak user data is located in the default directory: `~/.var/app`. -- Warehouse does not aim to replace flatpak; it simply facilitates appropriate flatpak commands for the desired actions. +- Warehouse assumes Flatpak user data is located in the default directory: `~/.var/app`. +- Warehouse does not aim to replace Flatpak; it simply facilitates appropriate Flatpak commands for the desired actions. - This project is still in its early stages, developed by a newcomer. Your understanding of potential bugs is greatly appreciated. ## đŸ› ī¸ Installation from Repo Steps: -1. Visit the [releases](https://github.com/flattool/warehouse/releases) page and download `io.github.flattool.Warehouse.flatpak`. +1. Visit the [releases](https://github.com/flattool/warehouse/releases) page and download `io.github.flattool.Warehouse.Flatpak`. 2. Install it using your software store or run the following command: ```shell flatpak install /path/to/io.github.flattool.Warehouse.flatpak diff --git a/app_page_screeshots/BatchMode.png b/app_page_screeshots/BatchMode.png deleted file mode 100644 index 4541d34..0000000 Binary files a/app_page_screeshots/BatchMode.png and /dev/null differ diff --git a/app_page_screeshots/Downgrade.png b/app_page_screeshots/Downgrade.png deleted file mode 100644 index 18ae6c0..0000000 Binary files a/app_page_screeshots/Downgrade.png and /dev/null differ diff --git a/app_page_screeshots/MainView.png b/app_page_screeshots/MainView.png deleted file mode 100644 index c0512e0..0000000 Binary files a/app_page_screeshots/MainView.png and /dev/null differ diff --git a/app_page_screeshots/Orphans.png b/app_page_screeshots/Orphans.png deleted file mode 100644 index 710b501..0000000 Binary files a/app_page_screeshots/Orphans.png and /dev/null differ diff --git a/app_page_screeshots/Properties.png b/app_page_screeshots/Properties.png deleted file mode 100644 index 2383451..0000000 Binary files a/app_page_screeshots/Properties.png and /dev/null differ diff --git a/app_page_screeshots/Remotes.png b/app_page_screeshots/Remotes.png deleted file mode 100644 index 5c1b6f1..0000000 Binary files a/app_page_screeshots/Remotes.png and /dev/null differ diff --git a/app_page_screeshots/SearchInstall.png b/app_page_screeshots/SearchInstall.png deleted file mode 100644 index ee23751..0000000 Binary files a/app_page_screeshots/SearchInstall.png and /dev/null differ diff --git a/app_page_screeshots/Snapshots.png b/app_page_screeshots/Snapshots.png deleted file mode 100644 index 932a8d7..0000000 Binary files a/app_page_screeshots/Snapshots.png and /dev/null differ diff --git a/app_page_screeshots/data_page_wide.png b/app_page_screeshots/data_page_wide.png new file mode 100644 index 0000000..ee18e05 Binary files /dev/null and b/app_page_screeshots/data_page_wide.png differ diff --git a/app_page_screeshots/install_page_skinny.png b/app_page_screeshots/install_page_skinny.png new file mode 100644 index 0000000..69f01ed Binary files /dev/null and b/app_page_screeshots/install_page_skinny.png differ diff --git a/app_page_screeshots/install_page_wide.png b/app_page_screeshots/install_page_wide.png new file mode 100644 index 0000000..8813324 Binary files /dev/null and b/app_page_screeshots/install_page_wide.png differ diff --git a/app_page_screeshots/packages_page_wide.png b/app_page_screeshots/packages_page_wide.png new file mode 100644 index 0000000..14998fa Binary files /dev/null and b/app_page_screeshots/packages_page_wide.png differ diff --git a/app_page_screeshots/propteries_page_skinny.png b/app_page_screeshots/propteries_page_skinny.png new file mode 100644 index 0000000..56b9f75 Binary files /dev/null and b/app_page_screeshots/propteries_page_skinny.png differ diff --git a/app_page_screeshots/remotes_page_wide.png b/app_page_screeshots/remotes_page_wide.png new file mode 100644 index 0000000..67ed909 Binary files /dev/null and b/app_page_screeshots/remotes_page_wide.png differ diff --git a/app_page_screeshots/snapshots_page_wide.png b/app_page_screeshots/snapshots_page_wide.png new file mode 100644 index 0000000..4194066 Binary files /dev/null and b/app_page_screeshots/snapshots_page_wide.png differ diff --git a/data/icons/arrow-pointing-at-line-down-symbolic.svg b/data/icons/arrow-pointing-at-line-down-symbolic.svg new file mode 100644 index 0000000..eaefb41 --- /dev/null +++ b/data/icons/arrow-pointing-at-line-down-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/arrow-turn-down-right-symbolic.svg b/data/icons/arrow-turn-down-right-symbolic.svg new file mode 100644 index 0000000..09bd65f --- /dev/null +++ b/data/icons/arrow-turn-down-right-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/copy-symbolic.svg b/data/icons/copy-symbolic.svg new file mode 100644 index 0000000..7aad5a3 --- /dev/null +++ b/data/icons/copy-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/dock-left-symbolic.svg b/data/icons/dock-left-symbolic.svg new file mode 100644 index 0000000..3c51520 --- /dev/null +++ b/data/icons/dock-left-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/dot-symbolic.svg b/data/icons/dot-symbolic.svg new file mode 100644 index 0000000..c94c8e1 --- /dev/null +++ b/data/icons/dot-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/double-ended-arrows-vertical-symbolic.svg b/data/icons/double-ended-arrows-vertical-symbolic.svg new file mode 100644 index 0000000..c6a52a5 --- /dev/null +++ b/data/icons/double-ended-arrows-vertical-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/edit-symbolic.svg b/data/icons/edit-symbolic.svg new file mode 100644 index 0000000..51090b9 --- /dev/null +++ b/data/icons/edit-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/error-small-symbolic.svg b/data/icons/error-small-symbolic.svg new file mode 100644 index 0000000..6a01fc3 --- /dev/null +++ b/data/icons/error-small-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/file-manager-symbolic.svg b/data/icons/file-manager-symbolic.svg new file mode 100644 index 0000000..db0aa5a --- /dev/null +++ b/data/icons/file-manager-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/folder-open-symbolic.svg b/data/icons/folder-open-symbolic.svg new file mode 100644 index 0000000..49db816 --- /dev/null +++ b/data/icons/folder-open-symbolic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/data/icons/folder-templates-symbolic.svg b/data/icons/folder-templates-symbolic.svg new file mode 100644 index 0000000..4e5faac --- /dev/null +++ b/data/icons/folder-templates-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/icons/font-x-generic-symbolic.svg b/data/icons/font-x-generic-symbolic.svg new file mode 100644 index 0000000..4978a46 --- /dev/null +++ b/data/icons/font-x-generic-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/icons/harddisk-symbolic.svg b/data/icons/harddisk-symbolic.svg new file mode 100644 index 0000000..75214bd --- /dev/null +++ b/data/icons/harddisk-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/list-remove-all-symbolic.svg b/data/icons/list-remove-all-symbolic.svg new file mode 100644 index 0000000..ae240aa --- /dev/null +++ b/data/icons/list-remove-all-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/icons/loupe-large-symbolic.svg b/data/icons/loupe-large-symbolic.svg new file mode 100644 index 0000000..8472dea --- /dev/null +++ b/data/icons/loupe-large-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/minus-large-symbolic.svg b/data/icons/minus-large-symbolic.svg new file mode 100644 index 0000000..09943ae --- /dev/null +++ b/data/icons/minus-large-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/padlock2-symbolic.svg b/data/icons/padlock2-symbolic.svg new file mode 100644 index 0000000..d385daa --- /dev/null +++ b/data/icons/padlock2-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/pin-small-symbolic.svg b/data/icons/pin-small-symbolic.svg new file mode 100644 index 0000000..d29ea96 --- /dev/null +++ b/data/icons/pin-small-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/pin-symbolic.svg b/data/icons/pin-symbolic.svg new file mode 100644 index 0000000..138e290 --- /dev/null +++ b/data/icons/pin-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/server-pick-symbolic.svg b/data/icons/server-pick-symbolic.svg new file mode 100644 index 0000000..7ea37f1 --- /dev/null +++ b/data/icons/server-pick-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/snapshots-alt-symbolic.svg b/data/icons/snapshots-alt-symbolic.svg new file mode 100644 index 0000000..f6bc5b7 --- /dev/null +++ b/data/icons/snapshots-alt-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/tag-outline-symbolic.svg b/data/icons/tag-outline-symbolic.svg new file mode 100644 index 0000000..2ed4768 --- /dev/null +++ b/data/icons/tag-outline-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/vertical-arrows-long-symbolic.svg b/data/icons/vertical-arrows-long-symbolic.svg new file mode 100644 index 0000000..ac7a6ee --- /dev/null +++ b/data/icons/vertical-arrows-long-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/view-list-bullet-symbolic.svg b/data/icons/view-list-bullet-symbolic.svg new file mode 100644 index 0000000..30198f0 --- /dev/null +++ b/data/icons/view-list-bullet-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/icons/view-sort-ascending-symbolic.svg b/data/icons/view-sort-ascending-symbolic.svg new file mode 100644 index 0000000..bd303f0 --- /dev/null +++ b/data/icons/view-sort-ascending-symbolic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/data/icons/view-sort-descending-symbolic.svg b/data/icons/view-sort-descending-symbolic.svg new file mode 100644 index 0000000..c620c7d --- /dev/null +++ b/data/icons/view-sort-descending-symbolic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/data/io.github.flattool.Warehouse.gschema.xml b/data/io.github.flattool.Warehouse.gschema.xml index 788abef..72aeb0e 100644 --- a/data/io.github.flattool.Warehouse.gschema.xml +++ b/data/io.github.flattool.Warehouse.gschema.xml @@ -13,6 +13,12 @@ false + + true + + + "packages" + @@ -28,4 +34,12 @@ "all" + + + false + + + "size" + + diff --git a/data/io.github.flattool.Warehouse.metainfo.xml.in b/data/io.github.flattool.Warehouse.metainfo.xml.in index 66819e9..4fda617 100644 --- a/data/io.github.flattool.Warehouse.metainfo.xml.in +++ b/data/io.github.flattool.Warehouse.metainfo.xml.in @@ -8,26 +8,29 @@ GPL-3.0-only Manage all things Flatpak -

Warehouse is an app that manages installed Flatpaks, their user data, and Flatpak remotes.

+

Warehouse provides a simple UI to control complex Flatpak options, all without resorting to the command line.

Features:

- +
#AECEF4 #072F5E - + keyboard pointing touch - + 330 @@ -39,54 +42,68 @@ https://ko-fi.com/heliguy - https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/MainView.png + https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/packages_page_wide.png + Manage Installed Packages in Three Pane UI - https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/BatchMode.png + https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/propteries_page_skinny.png + Properties Page in Narrow Window - https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/Properties.png + https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/remotes_page_wide.png + Manage Installed Remotes and Add New Remotes - https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/Remotes.png + https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/data_page_wide.png + Manage Apps' User Data - https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/SearchInstall.png + https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/snapshots_page_wide.png + Backup Apps' User Data - https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/Snapshots.png + https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/install_page_wide.png + Install New Packages from Files or Remotes - https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/Orphans.png - - - https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/Downgrade.png + https://raw.githubusercontent.com/flattool/warehouse/main/app_page_screeshots/install_page_skinny.png + Install Page in Narrow Window - + -

New Features and Changes

+

New Features

    -
  • Bump GNOME runtime version to 47
  • +
  • All new UI to make using Warehouse's features easier
  • +
  • UI now better adapts to larger window sizes
  • +
  • UI now better adapts to smaller window sizes
  • +
  • Improved UI for installing packages from remotes
  • +
  • Snapshots can be given names
  • +
  • Packages can be reinstalled with a few clicks
  • +
  • User Data can now be sorted in multiple ways
  • +
  • Active User Data can be browsed just as easily as leftover User Data
  • +
  • Custom installation locations are now supported
  • +
  • Leftover Snapshots are now shown
  • +
  • Apps can be reinstalled from leftover Snapshots
  • +
  • Installation location of disabled remotes is now shown
-

Bug Fixes

+

Changes

    -
  • Fix issue causing crash on startup due to Flatpaks with multi-line descriptions
  • -
  • Fix Properties Window's layout when a long package name is shown
  • +
  • Packages list filter options are now easier to understand, and more predictable with how they are applied
  • +
  • Improved keyboard shortcuts for quick navigation
  • +
  • The Downgrades (renamed to Change Version) interface now shows the currently installed version
  • +
  • Long running processes now have progress bars and can be canceled
  • +
  • Better status icons for End of Life, Masked, and Pinned packages
  • +
  • Warehouse no longer disables closing its window when long running processes are happening
  • +
  • Refreshing now shows a loading animation
-
-
- - -

Bux Fixes

+

Bug Fixes and Performance Improvements

    -
  • Fix issue causing downgrade window to not be able to downgrade anything
  • -
-

Previous Releases's Bug Fixes

-
    -
  • Downgrade Window no longer silently fails when downgrading a masked Flatpak, and instead, downgrades it
  • -
  • When downgrading and masking system Flatpaks, the password prompt only happens once instead of twice
  • +
  • Warehouse is now faster to open
  • +
  • Getting system information is now faster
  • +
  • Long running processes no longer freeze the app
  • +
  • Refreshing is no longer possible when long running processes are happening
diff --git a/data/style.css b/data/style.css index e69de29..af75c8b 100644 --- a/data/style.css +++ b/data/style.css @@ -0,0 +1,10 @@ +/* Thanks, TheEvilSkeleton! */ +.drag-overlay-status-page { + background-color: alpha(var(--accent-bg-color), 0.5); + color: var(--accent-fg-color); + padding: 32px; +} + +.blurred { + filter: blur(6px); +} diff --git a/data/ui/downgrade.blp b/data/ui/downgrade.blp deleted file mode 100644 index 9454a82..0000000 --- a/data/ui/downgrade.blp +++ /dev/null @@ -1,84 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $DowngradeWindow: Adw.Dialog { - content-width: 500; - content-height: 450; - - Adw.ToolbarView main_toolbar_view { - [top] - Adw.HeaderBar header_bar { - } - - [bottom] - ActionBar action_bar { - revealed: false; - - [center] - Button apply_button { - visible: false; - valign: end; - halign: center; - margin-top: 6; - margin-bottom: 6; - Adw.ButtonContent { - label: _("Downgrade"); - icon-name: "arrow-turn-left-down-symbolic"; - } - - styles [ - "suggested-action", - "pill" - ] - } - } - - content: Adw.ToastOverlay toast_overlay { - Stack main_stack { - Box loading { - orientation: vertical; - spacing: 10; - margin-top: 40; - margin-bottom: 20; - halign: center; - valign: center; - - Spinner { - margin-bottom: 35; - width-request: 30; - height-request: 30; - opacity: 0.5; - spinning: true; - } - - Label loading_label { - label: _("Fetching Releases"); - styles [ - "title-1", - "title" - ] - } - - Label { - label: _("This could take a while"); - styles ["description", "body"] - } - } - - Adw.PreferencesPage outerbox { - Adw.PreferencesGroup { - Adw.SwitchRow mask_row { - title: _("Disable Updates"); - active: true; - } - } - - Adw.PreferencesGroup versions_group { - title: _("Select a Release"); - description: _("This will uninstall the current release and install the chosen one instead. Note that downgrading can cause issues."); - } - } - } - }; - } -} diff --git a/data/ui/filter.blp b/data/ui/filter.blp deleted file mode 100644 index 093ec0c..0000000 --- a/data/ui/filter.blp +++ /dev/null @@ -1,83 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $FilterWindow: Adw.Dialog { - title: _("Set Filters"); - content-width: 500; - content-height: 450; - - child: - Adw.ToolbarView main_toolbar_view { - [top] - Adw.HeaderBar header_bar { - } - - content: Adw.ToastOverlay toast_overlay { - Stack main_stack { - Overlay main_overlay { - ScrolledWindow scrolled_window { - vexpand: true; - - Adw.Clamp { - Box outerbox { - orientation: vertical; - - ListBox install_type_list { - margin-top: 12; - margin-bottom: 12; - margin-start: 12; - margin-end: 12; - hexpand: true; - valign: start; - selection-mode: none; - - styles [ - "boxed-list" - ] - - Adw.ActionRow apps_row { - title: _("Show Apps"); - - Switch show_apps_switch { - valign: center; - } - - activatable-widget: show_apps_switch; - } - - Adw.ActionRow show_runtimes_row { - title: _("Show Runtimes"); - - Switch show_runtimes_switch { - valign: center; - } - - activatable-widget: show_runtimes_switch; - } - - Adw.ExpanderRow remotes_expander { - enable-expansion: true; - title: _("Filter by Remotes"); - } - - Adw.ExpanderRow runtimes_expander { - enable-expansion: true; - title: _("Filter by Runtimes"); - } - } - - Button reset_button { - visible: true; - margin-bottom: 18; - halign: center; - label: _("Reset Filters"); - styles ["pill"] - } - } - } - } - } - } - }; - }; -} diff --git a/data/ui/orphans.blp b/data/ui/orphans.blp deleted file mode 100644 index 0bb2b37..0000000 --- a/data/ui/orphans.blp +++ /dev/null @@ -1,135 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $OrphansWindow: Adw.Dialog { - content-width: 500; - content-height: 450; - - Adw.ToolbarView main_toolbar_view { - [top] - Adw.HeaderBar header_bar { - [start] - ToggleButton search_button { - icon-name: "system-search-symbolic"; - tooltip-text: _("Search List"); - } - - [end] - Button oepn_folder_button { - icon-name: "document-open-symbolic"; - tooltip-text: _("Open Data Folder"); - } - } - - [top] - SearchBar search_bar { - search-mode-enabled: bind search_button.active bidirectional; - key-capture-widget: main_toolbar_view; - - Adw.Clamp { - maximum-size: 577; - hexpand: true; - - SearchEntry search_entry {} - } - } - - content: Adw.ToastOverlay toast_overlay { - Overlay main_overlay { - Stack main_stack { - Box main_box { - orientation: vertical; - - ScrolledWindow scrolled_window { - vexpand: true; - - Adw.Clamp { - ListBox list_of_data { - margin-top: 12; - margin-bottom: 12; - margin-start: 12; - margin-end: 12; - hexpand: true; - valign: start; - selection-mode: none; - - styles [ - "boxed-list" - ] - } - } - } - } - - Box installing { - orientation: vertical; - spacing: 10; - margin-top: 40; - margin-bottom: 20; - halign: center; - valign: center; - - Spinner spinner { - margin-bottom: 35; - width-request: 30; - height-request: 30; - opacity: 0.5; - spinning: true; - } - - Label { - label: _("Installing"); - - styles [ - "title-1", - "title" - ] - } - - Label installing_status { - label: ""; - justify: center; - - styles [ - "description", - "body" - ] - } - } - - Adw.StatusPage no_data { - icon-name: "check-plain-symbolic"; - title: _("No Leftover Data"); - description: _("There is no leftover user data"); - } - - Adw.StatusPage no_results { - icon-name: "system-search-symbolic"; - title: _("No Results Found"); - description: _("Try a different search term"); - } - } - } - }; - - [bottom] - ActionBar action_bar { - [start] - ToggleButton select_all_button { - label: _("Select All"); - } - - [end] - Button trash_button { - label: _("Trash"); - sensitive: false; - } - - [end] - Button install_button { - label: _("Install"); - sensitive: false; - } - } - } -} diff --git a/data/ui/popular_remotes.blp b/data/ui/popular_remotes.blp deleted file mode 100644 index 5625e21..0000000 --- a/data/ui/popular_remotes.blp +++ /dev/null @@ -1,59 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $PopularRemotesWindow: Adw.Window { - default-width: 450; - default-height: 530; - title: ""; - - Adw.ToolbarView main_toolbar_view { - [top] - HeaderBar header_bar {} - - content: Adw.ToastOverlay toast_overlay { - vexpand: true; - - Adw.StatusPage { - valign: start; - title: _("Add Remote"); - description: _("Choose from a list of popular remotes or add a new one"); - - Adw.Clamp { - Box { - orientation: vertical; - - ListBox list_of_remotes { - hexpand: true; - valign: start; - selection-mode: none; - - styles [ - "boxed-list" - ] - } - - ListBox custom_list { - hexpand: true; - valign: start; - selection-mode: none; - - styles [ - "boxed-list" - ] - - Adw.ActionRow add_from_file { - title: _("Add a Repo File"); - activatable: true; - } - - Adw.ActionRow custom_remote { - title: _("Add a Custom Remote"); - activatable: true; - } - } - } - } - } - }; - } -} diff --git a/data/ui/properties.blp b/data/ui/properties.blp deleted file mode 100644 index 42b666a..0000000 --- a/data/ui/properties.blp +++ /dev/null @@ -1,167 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $PropertiesWindow: Adw.Dialog { - content-width: 350; - content-height: 999999; - title: _("Properties"); - - Adw.ToolbarView main_toolbar_view { - [top] - Adw.HeaderBar header_bar {} - - content: Adw.ToastOverlay toast_overlay { - Box { - orientation: vertical; - - Adw.Banner eol_app_banner {} - - Adw.Banner eol_runtime_banner {} - - Adw.Banner mask_banner {} - - ScrolledWindow { - Adw.Clamp { - Box { - orientation: vertical; - hexpand: false; - vexpand: true; - spacing: 12; - margin-top: 12; - margin-start: 12; - margin-end: 12; - margin-bottom: 12; - - Image app_icon { - pixel-size: 100; - - styles [ - "icon-dropshadow" - ] - } - - Label name { - wrap: true; - wrap-mode: word_char; - justify: center; - - styles [ - "title-1" - ] - } - - Button description_button { - visible: bind description.visible; - styles [ - "title-4", - "flat" - ] - - Box { - spacing: 12; - - Label description { - halign: start; - wrap: true; - hexpand: true; - } - Image { - icon-name: "edit-copy-symbolic"; - } - } - } - - Adw.PreferencesGroup upper { - Adw.ActionRow data_row { - title: _("Loading User Data"); - - [suffix] - Button open_data { - icon-name: "document-open-symbolic"; - tooltip-text: _("Open User Data Folder"); - valign: center; - visible: false; - - styles [ - "flat" - ] - } - - [suffix] - Button trash_data { - icon-name: "user-trash-symbolic"; - tooltip-text: _("Trash User Data"); - valign: center; - visible: false; - - styles [ - "flat" - ] - } - - [suffix] - Spinner spinner { - spinning: true; - } - - styles["property"] - } - - Adw.ActionRow view_apps { - title: _("Show Apps Using This Runtime"); - activatable: true; - visible: false; - - [suffix] - Image { - icon-name: "funnel-symbolic"; - } - } - - Adw.ActionRow runtime { - title: _("Runtime"); - - [suffix] - Button runtime_properties { - icon-name: "info-symbolic"; - tooltip-text: _("View Properties"); - valign: center; - - styles [ - "flat" - ] - } - - [suffix] - Button runtime_copy { - icon-name: "edit-copy-symbolic"; - tooltip-text: _("Copy"); - valign: center; - - styles [ - "flat" - ] - } - - styles["property"] - } - - Adw.ActionRow details { - title: _("Show Details in Store"); - activatable: true; - - [suffix] - Image { - icon-name: "adw-external-link-symbolic"; - } - } - } - - Adw.PreferencesGroup lower {} - } - } - } - } - }; - } -} diff --git a/data/ui/remotes.blp b/data/ui/remotes.blp deleted file mode 100644 index ed42b67..0000000 --- a/data/ui/remotes.blp +++ /dev/null @@ -1,95 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $RemotesWindow: Adw.Dialog { - title: _("Manage Remotes"); - content-width: 500; - content-height: 450; - - Adw.ToolbarView main_toolbar_view { - [top] - Adw.HeaderBar header_bar { - Button refresh { - icon-name: "view-refresh-symbolic"; - tooltip-text: _("Refresh List of Remotes"); - } - } - - content: Adw.ToastOverlay toast_overlay { - Stack stack { - - Adw.PreferencesPage main_group { - Adw.PreferencesGroup remotes_list { - title: _("Installed Remotes"); - header-suffix: ToggleButton show_disabled_button { - Adw.ButtonContent show_disabled_button_button_content { - icon-name: "eye-not-looking-symbolic"; - label: _("Show Disabled"); - styles["flat"] - } - // spacing: 6; - // margin-end: 6; - // Label { - // label: _("Show Disabled"); - // styles["heading", "h4"] - // } - // Switch show_disabled { - // valign: center; - // } - }; - Adw.ActionRow no_remotes { - title: _("No Remotes Found"); - } - } - Adw.PreferencesGroup popular_remotes_list { - title: _("Add a Popular Remote"); - visible: false; - } - Adw.PreferencesGroup manual_remotes_list { - title: _("Add Other Remotes"); - Adw.ActionRow add_from_file { - title: _("Add a Repo File"); - activatable: true; - } - - Adw.ActionRow custom_remote { - title: _("Add a Custom Remote"); - activatable: true; - } - } - } - - Box adding { - orientation: vertical; - spacing: 10; - margin-top: 40; - margin-bottom: 20; - halign: center; - valign: center; - - Spinner { - margin-bottom: 35; - width-request: 30; - height-request: 30; - opacity: 0.5; - spinning: true; - } - - Label { - label: _("Adding Remote"); - - styles [ - "title-1", - "title" - ] - } - - Label { - label: _("This should only take a moment"); - styles ["description", "body"] - } - } - } - }; - } -} diff --git a/data/ui/search_install.blp b/data/ui/search_install.blp deleted file mode 100644 index 7d06b35..0000000 --- a/data/ui/search_install.blp +++ /dev/null @@ -1,193 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $SearchInstallWindow: Adw.Dialog { - content-width: 500; - content-height: 450; - title: ""; - - Adw.ToolbarView main_toolbar_view { - content: - Stack outer_stack { - Adw.NavigationView nav_view { - Adw.NavigationPage search_page { - title: _("Search Criteria"); - Adw.ToastOverlay toast_overlay { - Adw.ToolbarView { - [top] - Adw.HeaderBar { - } - content: - Adw.StatusPage { - title: _("Choose a Remote to Search"); - valign: start; - child: - Adw.Clamp { - ListBox remotes_list { - selection-mode: none; - styles ["boxed-list"] - } - }; - }; - } - } - } - Adw.NavigationPage results_page { - title: _("Results"); - Adw.ToolbarView { - [top] - Adw.HeaderBar { - } - [bottom] - ActionBar action_bar { - revealed: false; - [center] - Button install_button { - margin-top: 6; - margin-bottom: 6; - styles[ - "pill", - "suggested-action" - ] - - Adw.ButtonContent { - label: _("Install"); - icon-name: "plus-large-symbolic"; - } - } - } - [top] - Adw.Clamp { - Box search_box { - margin-top: 4; - margin-start: 12; - margin-end: 12; - margin-bottom: 6; - SearchEntry search_entry { - hexpand: true; - } - Button search_button { - icon-name: "right-large-symbolic"; - tooltip-text: _("Start Search"); - } - styles ["linked"] - } - } - content: - Stack inner_stack { - - Adw.StatusPage blank_page { - title: _("Search for Flatpaks"); - icon-name: "flatpak-symbolic"; - description: _("Search for Flatpaks that you want to install"); - } - - Adw.StatusPage no_results { - icon-name: "system-search-symbolic"; - title: _("No Results Found"); - description: _("Try a different search term"); - } - - Box loading_page { - orientation: vertical; - spacing: 10; - margin-top: 40; - margin-bottom: 20; - halign: center; - valign: center; - - Spinner { - margin-bottom: 35; - width-request: 30; - height-request: 30; - opacity: 0.5; - spinning: true; - } - - Label { - label: _("Searching"); - - styles [ - "title-1", - "title" - ] - } - } - - Adw.StatusPage too_many { - icon-name: "error-symbolic"; - title: _("Too Many Results"); - description: _("Try being more specific with your search"); - } - - ScrolledWindow results_scroll { - vexpand: true; - Adw.Clamp { - ListBox results_list { - margin-top: 6; - margin-bottom: 12; - margin-start: 12; - margin-end: 12; - hexpand: true; - valign: start; - selection-mode: none; - styles ["boxed-list"] - } - } - } - }; - } - } - } - Adw.ToolbarView installing { - [top] - Adw.HeaderBar { - } - content: - Overlay overlay { - [overlay] - ProgressBar progress_bar { - visible: false; - can-target: false; - styles ["osd"] - } - Box { - orientation: vertical; - spacing: 10; - margin-top: 40; - margin-bottom: 20; - halign: center; - valign: center; - - Spinner { - margin-bottom: 35; - width-request: 30; - height-request: 30; - opacity: 0.5; - spinning: true; - } - - Label { - label: _("Installing"); - - styles [ - "title-1", - "title" - ] - } - - Label installing_status { - label: ""; - justify: center; - - styles [ - "description", - "body" - ] - } - } - }; - } - }; - } -} diff --git a/data/ui/snapshots.blp b/data/ui/snapshots.blp deleted file mode 100644 index 6946529..0000000 --- a/data/ui/snapshots.blp +++ /dev/null @@ -1,90 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $SnapshotsWindow: Adw.Dialog { - content-width: 500; - content-height: 455; - - Adw.ToolbarView main_toolbar_view { - [top] - Adw.HeaderBar header_bar { - [end] - Button open_folder_button { - icon-name: "document-open-symbolic"; - tooltip-text: _("Open Snapshots Folder"); - } - } - - [bottom] - ActionBar action_bar { - [center] - Button new_snapshot { - halign: center; - sensitive: bind action_bar.revealed; - margin-top: 6; - margin-bottom: 6; - styles[ - "pill", - "suggested-action" - ] - - Adw.ButtonContent { - label: _("New Snapshot"); - icon-name: "plus-large-symbolic"; - } - } - } - - content: Adw.ToastOverlay toast_overlay { - Stack main_stack { - ScrolledWindow outerbox { - Adw.Clamp { - ListBox snapshots_group { - margin-top: 12; - margin-bottom: 12; - margin-start: 12; - margin-end: 12; - valign: start; - selection-mode: none; - - styles [ - "boxed-list" - ] - } - } - } - - Box loading { - orientation: vertical; - spacing: 10; - margin-top: 40; - margin-bottom: 20; - halign: center; - valign: center; - - Spinner { - margin-bottom: 35; - width-request: 30; - height-request: 30; - opacity: 0.5; - spinning: true; - } - - Label loading_label { - styles [ - "title-1", - "title" - ] - } - } - - - Adw.StatusPage no_snapshots { - title: _("No Snapshots"); - description: _("Snapshots are backups of the app's user data. They can be reapplied at any time."); - icon-name: "clock-alt-symbolic"; - } - } - }; - } -} diff --git a/data/ui/window.blp b/data/ui/window.blp deleted file mode 100644 index 591666f..0000000 --- a/data/ui/window.blp +++ /dev/null @@ -1,299 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $WarehouseWindow: Adw.ApplicationWindow { - title: "Warehouse"; - Adw.ToolbarView main_toolbar_view { - [top] - HeaderBar header_bar { - [start] - ToggleButton search_button { - icon-name: "system-search-symbolic"; - tooltip-text: _("Search List"); - } - - [start] - Button filter_button { - icon-name: "funnel-symbolic"; - tooltip-text: _("Filter List"); - } - - [end] - MenuButton main_menu { - icon-name: "open-menu-symbolic"; - tooltip-text: _("Main Menu"); - menu-model: primary_menu; - } - - [end] - ToggleButton batch_mode_button { - icon-name: "selection-mode-symbolic"; - tooltip-text: _("Toggle Selection Mode"); - } - } - - [top] - SearchBar search_bar { - search-mode-enabled: bind search_button.active bidirectional; - key-capture-widget: main_toolbar_view; - - Adw.Clamp { - maximum-size: 577; - hexpand: true; - - SearchEntry search_entry {} - } - } - - content: Adw.ToastOverlay toast_overlay { - Overlay main_overlay { - Stack main_stack { - Adw.StatusPage loading_flatpaks { - icon-name: "clock-alt-symbolic"; - title: _("Loading Flatpaks"); - description: _("This should only take a moment"); - } - - Box main_box { - orientation: vertical; - - ScrolledWindow scrolled_window { - vexpand: true; - - Adw.Clamp { - ListBox flatpaks_list_box { - margin-top: 12; - margin-bottom: 12; - margin-start: 12; - margin-end: 12; - hexpand: true; - valign: start; - selection-mode: none; - - styles [ - "boxed-list" - ] - } - } - } - } - - Box installing { - orientation: vertical; - spacing: 10; - margin-top: 40; - margin-bottom: 20; - halign: center; - valign: center; - - Spinner { - margin-bottom: 35; - width-request: 30; - height-request: 30; - opacity: 0.5; - spinning: true; - } - - Label { - label: _("Installing"); - - styles [ - "title-1", - "title" - ] - } - - Label { - label: _("This could take a while"); - styles ["description", "body"] - } - } - - Box uninstalling { - orientation: vertical; - spacing: 10; - margin-top: 40; - margin-bottom: 20; - halign: center; - valign: center; - - Spinner { - margin-bottom: 35; - width-request: 30; - height-request: 30; - opacity: 0.5; - spinning: true; - } - - Label { - label: _("Uninstalling"); - styles ["title-1", "title"] - } - - Label uninstalling_status { - label: ""; - justify: center; - styles ["description", "body"] - } - } - - Box snapshotting { - orientation: vertical; - spacing: 10; - margin-top: 40; - margin-bottom: 20; - halign: center; - valign: center; - - Spinner { - margin-bottom: 35; - width-request: 30; - height-request: 30; - opacity: 0.5; - spinning: true; - } - - Label { - label: _("Creating Snapshots"); - styles ["title-1", "title"] - } - - Label { - label: _("This could take a while"); - styles ["description", "body"] - } - } - - Adw.StatusPage no_flatpaks { - icon-name: "error-symbolic"; - title: _("No Flatpaks Found"); - description: _("Warehouse cannot see the list of installed Flatpaks or the system has no Flatpaks installed"); - } - - Adw.StatusPage no_matches { - icon-name: "funnel-symbolic"; - title: _("No Flatpaks Match Filters"); - description: _("No installed Flatpak matches all of the currently applied filters"); - [child] - Button reset_filters_button { - label: _("Reset Filters"); - halign: center; - styles["pill"] - } - } - - Adw.StatusPage no_results { - icon-name: "system-search-symbolic"; - title: _("No Results Found"); - description: _("Try a different search term"); - } - - Adw.StatusPage refreshing { - icon-name: "arrow-circular-top-right-symbolic"; - title: _("Refreshing List"); - description: _("This should only take a moment"); - } - } - } - }; - - [bottom] - ActionBar batch_mode_bar { - revealed: false; - - [start] - ToggleButton batch_select_all_button { - label: _("Select All"); - } - - [end] - Button batch_uninstall_button { - icon-name: "cross-filled-symbolic"; - tooltip-text: _("Uninstall Selected Apps"); - } - - [end] - Button batch_clean_button { - icon-name: "user-trash-symbolic"; - tooltip-text: _("Send Selected Apps' Data to the Trash"); - } - - [end] - MenuButton batch_copy_button { - icon-name: "edit-copy-symbolic"; - tooltip-text: _("Open Copy Menu"); - menu-model: copy_menu; - } - - [end] - Button batch_snapshot_button { - icon-name: "clock-alt-symbolic"; - tooltip-text: _("Snapshot Selected Apps' Data"); - visible: true; - } - } - } -} - -menu primary_menu { - section { - item { - label: _("Manage Leftover Dataâ€Ļ"); - action: "app.manage-data-folders"; - } - - /*item { - label: _("_Preferences"); - action: "app.preferences"; - }*/ - item { - label: _("Manage Remotesâ€Ļ"); - action: "app.show-remotes-window"; - } - } - section { - item { - label: _("Install From Fileâ€Ļ"); - action: "app.install-from-file"; - } - - item { - label: _("Install From The Webâ€Ļ"); - action: "app.open-search-install"; - } - } - section { - item { - label: _("Refresh List"); - action: "app.refresh-list"; - } - item { - label: _("_Keyboard Shortcuts"); - action: "win.show-help-overlay"; - } - - item { - label: _("_About Warehouse"); - action: "app.about"; - } - } -} - -menu copy_menu { - section { - item { - label: _("Copy Names"); - action: "win.copy-names"; - } - - item { - label: _("Copy IDs"); - action: "win.copy-ids"; - } - - item { - label: _("Copy Refs"); - action: "win.copy-refs"; - } - } -} \ No newline at end of file diff --git a/io.github.flattool.Warehouse.json b/io.github.flattool.Warehouse.json index a357eba..e0cec7c 100644 --- a/io.github.flattool.Warehouse.json +++ b/io.github.flattool.Warehouse.json @@ -1,10 +1,10 @@ { - "id": "io.github.flattool.Warehouse", - "runtime": "org.gnome.Platform", - "runtime-version": "47", - "sdk": "org.gnome.Sdk", - "command": "warehouse", - "finish-args": [ + "id" : "io.github.flattool.Warehouse", + "runtime" : "org.gnome.Platform", + "runtime-version" : "47", + "sdk" : "org.gnome.Sdk", + "command" : "warehouse", + "finish-args" : [ "--share=ipc", "--socket=fallback-x11", "--device=dri", @@ -12,7 +12,8 @@ "--talk-name=org.freedesktop.Flatpak", "--filesystem=/var/lib/flatpak/:ro", "--filesystem=~/.local/share/flatpak/:ro", - "--filesystem=~/.var/app/" + "--filesystem=~/.var/app/", + "--filesystem=host-etc" ], "cleanup": [ "/include", @@ -31,19 +32,19 @@ "buildsystem": "meson", "sources": [ { - "type": "git", - "url": "https://gitlab.gnome.org/jwestman/blueprint-compiler", - "tag": "v0.10.0" + "type" : "git", + "url" : "https://gitlab.gnome.org/jwestman/blueprint-compiler", + "tag" : "v0.14.0" } ], "cleanup": ["*"] }, { - "name": "warehouse", - "builddir": true, - "buildsystem": "meson", - "config-opts": ["-Dprofile=default"], - "sources": [ + "name" : "warehouse", + "builddir" : true, + "buildsystem" : "meson", + "config-opts": [ "-Dprofile=development" ], + "sources" : [ { "type": "dir", "path": "." diff --git a/meson.build b/meson.build index abac2d9..e34c212 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('warehouse', - version: '1.6.5', + version: '2.0.0', meson_version: '>= 0.62.0', default_options: [ 'warning_level=2', 'werror=false', ], ) diff --git a/po/POTFILES b/po/POTFILES index 2fdd683..f830f1a 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -1,23 +1,61 @@ data/io.github.flattool.Warehouse.desktop.in data/io.github.flattool.Warehouse.metainfo.xml.in data/io.github.flattool.Warehouse.gschema.xml -data/ui/downgrade.blp -data/ui/filter.blp -data/ui/orphans.blp -data/ui/popular_remotes.blp -data/ui/properties.blp -data/ui/remotes.blp -data/ui/search_install.blp -data/ui/snapshots.blp -data/ui/window.blp -src/app_row_widget.py -src/common.py -src/downgrade_window.py -src/filter_window.py -src/gtk/help-overlay.blp src/main.py -src/orphans_window.py -src/properties_window.py -src/remotes_window.py -src/snapshots_window.py -src/window.py +src/host_info.py +src/package_install_worker.py +src/gtk/app_row.blp +src/gtk/attempt_install_dialog.blp +src/gtk/attempt_install_dialog.py +src/gtk/error_toast.py +src/gtk/help-overlay.blp +src/gtk/installation_chooser.blp +src/gtk/installation_chooser.py +src/gtk/loading_status.blp +src/gtk/sidebar_button.py +src/main_window/window.blp +src/main_window/window.py +src/packages_page/filters_page.blp +src/packages_page/filters_page.py +src/packages_page/packages_page.blp +src/packages_page/packages_page.py +src/packages_page/uninstall_dialog.blp +src/packages_page/uninstall_dialog.py +src/properties_page/properties_page.blp +src/properties_page/properties_page.py +src/change_version_page/change_version_page.blp +src/change_version_page/change_version_page.py +src/change_version_page/change_version_worker.py +src/remotes_page/add_remote_dialog.blp +src/remotes_page/add_remote_dialog.py +src/remotes_page/remote_row.blp +src/remotes_page/remote_row.py +src/remotes_page/remotes_page.blp +src/remotes_page/remotes_page.py +src/user_data_page/data_box.blp +src/user_data_page/data_box.py +src/user_data_page/data_subpage.blp +src/user_data_page/data_subpage.py +src/user_data_page/user_data_page.blp +src/user_data_page/user_data_page.py +src/snapshot_page/new_snapshot_dialog.blp +src/snapshot_page/new_snapshot_dialog.py +src/snapshot_page/snapshot_box.blp +src/snapshot_page/snapshot_box.py +src/snapshot_page/snapshot_page.blp +src/snapshot_page/snapshot_page.py +src/snapshot_page/snapshots_list_page.blp +src/snapshot_page/snapshots_list_page.py +src/snapshot_page/tar_worker.py +src/install_page/file_install_dialog.blp +src/install_page/file_install_dialog.py +src/install_page/install_page.blp +src/install_page/install_page.py +src/install_page/pending_page.blp +src/install_page/pending_page.py +src/install_page/result_row.blp +src/install_page/result_row.py +src/install_page/results_page.blp +src/install_page/results_page.py +src/install_page/select_page.blp +src/install_page/select_page.py diff --git a/po/warehouse.pot b/po/warehouse.pot index e49ec7c..ad68e5b 100644 --- a/po/warehouse.pot +++ b/po/warehouse.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: io.github.flattool.heliguy.Warehouse\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-03 14:05-0400\n" +"POT-Creation-Date: 2024-10-26 15:32-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -33,8 +33,8 @@ msgstr "" #: data/io.github.flattool.Warehouse.metainfo.xml.in:11 msgid "" -"Warehouse is an app that manages installed Flatpaks, their user data, and " -"Flatpak remotes." +"Warehouse provides a simple UI to control complex Flatpak options, all " +"without resorting to the command line." msgstr "" #: data/io.github.flattool.Warehouse.metainfo.xml.in:12 @@ -42,1003 +42,1778 @@ msgid "Features:" msgstr "" #: data/io.github.flattool.Warehouse.metainfo.xml.in:14 -msgid "Show and filter the list of installed Flatpaks" +msgid "Manage installed Flatpaks and view properties of any package" msgstr "" #: data/io.github.flattool.Warehouse.metainfo.xml.in:15 -msgid "Display properties of installed Flatpaks" +msgid "Change versions of a Flatpak to rollback any unwanted updates" msgstr "" #: data/io.github.flattool.Warehouse.metainfo.xml.in:16 -msgid "Manage large groups of Flatpaks at once" +msgid "Pin runtimes and mask Flatpaks" msgstr "" #: data/io.github.flattool.Warehouse.metainfo.xml.in:17 -msgid "Add and remove Flatpak remotes" +msgid "Filter packages and sort data, to help find anything easily" msgstr "" #: data/io.github.flattool.Warehouse.metainfo.xml.in:18 -msgid "Find and trash leftover user data" +msgid "See current app user data, and cleanup any unused data left behind" msgstr "" #: data/io.github.flattool.Warehouse.metainfo.xml.in:19 -msgid "Reinstall apps that have leftover data" +msgid "" +"Add popular Flatpak remotes with a few clicks or add custom remotes instead" msgstr "" -#: data/ui/downgrade.blp:25 src/app_row_widget.py:360 -msgid "Downgrade" +#: data/io.github.flattool.Warehouse.metainfo.xml.in:20 +msgid "Take snapshots of your apps' user data, saving your data" msgstr "" -#: data/ui/downgrade.blp:55 -msgid "Fetching Releases" +#: data/io.github.flattool.Warehouse.metainfo.xml.in:21 +msgid "Install new packages from any remote, or from your system" msgstr "" -#: data/ui/downgrade.blp:63 data/ui/window.blp:107 data/ui/window.blp:162 +#: data/io.github.flattool.Warehouse.metainfo.xml.in:22 +msgid "Responsive UI to fit large and small screen sizes" +msgstr "" + +#: data/io.github.flattool.Warehouse.metainfo.xml.in:46 +msgid "Manage Installed Packages in Three Pane UI" +msgstr "" + +#: data/io.github.flattool.Warehouse.metainfo.xml.in:50 +msgid "Properties Page in Narrow Window" +msgstr "" + +#: data/io.github.flattool.Warehouse.metainfo.xml.in:54 +msgid "Manage Installed Remotes and Add New Remotes" +msgstr "" + +#: data/io.github.flattool.Warehouse.metainfo.xml.in:58 +msgid "Manage Apps' User Data" +msgstr "" + +#: data/io.github.flattool.Warehouse.metainfo.xml.in:62 +msgid "Backup Apps' User Data" +msgstr "" + +#: data/io.github.flattool.Warehouse.metainfo.xml.in:66 +msgid "Install New Packages from Files or Remotes" +msgstr "" + +#: data/io.github.flattool.Warehouse.metainfo.xml.in:70 +msgid "Install Page in Narrow Window" +msgstr "" + +#: src/main.py:102 src/main.py:108 src/install_page/select_page.py:60 +#: src/install_page/select_page.py:67 +msgid "Could not add files" +msgstr "" + +#: src/main.py:102 +msgid "No files were found" +msgstr "" + +#: src/main.py:110 +msgid "Flatpaks & Remotes" +msgstr "" + +#. Translators: do one of the following, one per line: Your Name, Your Name , Your Name https://websi.te +#: src/main.py:218 +msgid "translator-credits" +msgstr "" + +#: src/main.py:224 +msgid "Donate" +msgstr "" + +#: src/main.py:226 +msgid "Contributors" +msgstr "" + +#: src/host_info.py:380 src/host_info.py:382 +msgid "Could not load packages" +msgstr "" + +#: src/package_install_worker.py:84 +msgid "Errors occurred during installation" +msgstr "" + +#: src/package_install_worker.py:88 src/package_install_worker.py:92 +msgid "Error occurred during installation" +msgstr "" + +#: src/package_install_worker.py:88 +#: src/change_version_page/change_version_worker.py:70 +msgid "Failed to exit cleanly" +msgstr "" + +#: src/package_install_worker.py:104 +msgid "Could not cancel installation" +msgstr "" + +#: src/package_install_worker.py:125 src/package_install_worker.py:135 +msgid "Could not install packages" +msgstr "" + +#: src/package_install_worker.py:125 +msgid "Packages are currently being installed." +msgstr "" + +#: src/package_install_worker.py:135 +msgid "No packages were requested to be installed." +msgstr "" + +#: src/gtk/app_row.blp:14 +msgid "This package is End Of Life, and will not receive any security updates" +msgstr "" + +#: src/gtk/app_row.blp:21 +msgid "" +"This app's runtime is End Of Life, and will not receive any security updates" +msgstr "" + +#: src/gtk/app_row.blp:28 +msgid "This runtime will never be automatically removed" +msgstr "" + +#: src/gtk/app_row.blp:34 +msgid "Updates are disabled for this package" +msgstr "" + +#: src/gtk/attempt_install_dialog.blp:5 +msgid "Attempt an Install?" +msgstr "" + +#: src/gtk/attempt_install_dialog.blp:6 +msgid "Warehouse will try to install the matching packages." +msgstr "" + +#: src/gtk/attempt_install_dialog.blp:8 src/gtk/loading_status.blp:52 +#: src/packages_page/uninstall_dialog.py:37 +#: src/properties_page/properties_page.py:189 +#: src/properties_page/properties_page.py:310 +#: src/remotes_page/add_remote_dialog.blp:17 src/remotes_page/remote_row.py:122 +#: src/remotes_page/remotes_page.py:189 src/user_data_page/data_box.py:114 +#: src/user_data_page/user_data_page.py:210 +#: src/snapshot_page/new_snapshot_dialog.blp:16 +#: src/snapshot_page/snapshot_box.py:113 src/snapshot_page/snapshot_box.py:155 +#: src/snapshot_page/snapshot_page.py:467 +#: src/snapshot_page/snapshot_page.py:508 +#: src/install_page/file_install_dialog.blp:13 +msgid "Cancel" +msgstr "" + +#: src/gtk/attempt_install_dialog.blp:9 +#: src/user_data_page/user_data_page.blp:100 +#: src/user_data_page/user_data_page.blp:144 +#: src/install_page/file_install_dialog.blp:18 +#: src/install_page/pending_page.blp:35 +msgid "Install" +msgstr "" + +#: src/gtk/attempt_install_dialog.blp:12 +msgid "Choose a Remote" +msgstr "" + +#: src/gtk/attempt_install_dialog.blp:13 +msgid "Select a remote to attempt to install from" +msgstr "" + +#: src/gtk/attempt_install_dialog.py:18 src/packages_page/filters_page.py:122 +#: src/remotes_page/remote_row.py:148 src/install_page/pending_page.py:29 +#: src/install_page/select_page.py:31 +msgid "Installation: {}" +msgstr "" + +#: src/gtk/attempt_install_dialog.py:71 +msgid "Can't find matching packages" +msgstr "" + +#: src/gtk/attempt_install_dialog.py:71 +msgid "Your system has no remotes added" +msgstr "" + +#. Extra Object Creation +#: src/gtk/error_toast.py:12 +msgid "Details" +msgstr "" + +#: src/gtk/error_toast.py:19 src/packages_page/packages_page.blp:135 +#: src/user_data_page/user_data_page.blp:90 +#: src/snapshot_page/snapshot_page.blp:149 +msgid "Copy" +msgstr "" + +#: src/gtk/error_toast.py:20 src/main_window/window.py:135 +#: src/main_window/window.py:145 src/main_window/window.py:154 +msgid "OK" +msgstr "" + +#: src/gtk/help-overlay.blp:9 +msgid "General" +msgstr "" + +#: src/gtk/help-overlay.blp:11 +msgid "Refresh" +msgstr "" + +#: src/gtk/help-overlay.blp:15 src/install_page/select_page.blp:40 +msgid "Open Files" +msgstr "" + +#: src/gtk/help-overlay.blp:19 +msgid "Open Menu" +msgstr "" + +#: src/gtk/help-overlay.blp:23 +msgid "Show Shortcuts" +msgstr "" + +#: src/gtk/help-overlay.blp:27 +msgid "Quit" +msgstr "" + +#: src/gtk/help-overlay.blp:32 +msgid "Navigation" +msgstr "" + +#: src/gtk/help-overlay.blp:34 +msgid "Show Packages Page" +msgstr "" + +#: src/gtk/help-overlay.blp:38 +msgid "Show Remotes Page" +msgstr "" + +#: src/gtk/help-overlay.blp:42 +msgid "Show User Data Page" +msgstr "" + +#: src/gtk/help-overlay.blp:46 +msgid "Show Snapshots Page" +msgstr "" + +#: src/gtk/help-overlay.blp:50 +msgid "Show Install Page" +msgstr "" + +#: src/gtk/help-overlay.blp:55 +msgid "Packages Page" +msgstr "" + +#: src/gtk/help-overlay.blp:57 src/gtk/help-overlay.blp:72 +#: src/gtk/help-overlay.blp:87 src/gtk/help-overlay.blp:110 +msgid "Search Mode" +msgstr "" + +#: src/gtk/help-overlay.blp:61 +msgid "Edit Filters" +msgstr "" + +#: src/gtk/help-overlay.blp:65 src/gtk/help-overlay.blp:95 +#: src/gtk/help-overlay.blp:114 +msgid "Selection Mode" +msgstr "" + +#: src/gtk/help-overlay.blp:70 +msgid "Remotes Page" +msgstr "" + +#: src/gtk/help-overlay.blp:76 +msgid "Show or Hide Disabled Remotes" +msgstr "" + +#: src/gtk/help-overlay.blp:80 +msgid "New Remote" +msgstr "" + +#: src/gtk/help-overlay.blp:85 +msgid "User Data Page" +msgstr "" + +#: src/gtk/help-overlay.blp:91 +msgid "Edit Sorting Modes" +msgstr "" + +#: src/gtk/help-overlay.blp:99 +msgid "Show Active Data" +msgstr "" + +#: src/gtk/help-overlay.blp:103 +msgid "Show Leftover Data" +msgstr "" + +#: src/gtk/help-overlay.blp:108 +msgid "Snapshots Page" +msgstr "" + +#: src/gtk/help-overlay.blp:118 src/snapshot_page/new_snapshot_dialog.py:203 +msgid "New Snapshots" +msgstr "" + +#: src/gtk/installation_chooser.blp:5 +msgid "Choose Installation" +msgstr "" + +#: src/gtk/installation_chooser.blp:11 +msgid "User" +msgstr "" + +#: src/gtk/installation_chooser.blp:19 +msgid "System" +msgstr "" + +#: src/gtk/installation_chooser.blp:28 +msgid "Custom installation" +msgstr "" + +#: src/gtk/installation_chooser.blp:37 +msgid "Other Installation" +msgstr "" + +#: src/gtk/installation_chooser.blp:38 +msgid "Choose a custom installation" +msgstr "" + +#: src/gtk/installation_chooser.py:27 +msgid "These {} will only be available to you" +msgstr "" + +#: src/gtk/installation_chooser.py:28 +msgid "These {} will be available to everyone" +msgstr "" + +#: src/gtk/installation_chooser.py:29 +msgid "Choose how these {} will be installed" +msgstr "" + +#: src/gtk/installation_chooser.py:31 +msgid "This {} will only be available to you" +msgstr "" + +#: src/gtk/installation_chooser.py:32 +msgid "This {} will be available to everyone" +msgstr "" + +#: src/gtk/installation_chooser.py:33 +msgid "Choose how this {} will be installed" +msgstr "" + +#: src/gtk/sidebar_button.py:19 +msgid "Show Sidebar" +msgstr "" + +#: src/main_window/window.blp:24 +msgid "Drop to Open" +msgstr "" + +#: src/main_window/window.blp:25 +msgid "Install Flatpaks or Add a Remote" +msgstr "" + +#: src/main_window/window.blp:43 +msgid "Refresh List" +msgstr "" + +#: src/main_window/window.blp:48 +msgid "Main Menu" +msgstr "" + +#: src/main_window/window.blp:66 src/packages_page/packages_page.blp:20 +#: src/packages_page/packages_page.blp:55 +#: src/packages_page/packages_page.py:154 +#: src/packages_page/packages_page.py:287 +#: src/install_page/file_install_dialog.py:37 +msgid "Packages" +msgstr "" + +#: src/main_window/window.blp:79 +msgid "Remotes" +msgstr "" + +#: src/main_window/window.blp:93 src/properties_page/properties_page.blp:114 +#: src/user_data_page/user_data_page.blp:20 +msgid "User Data" +msgstr "" + +#: src/main_window/window.blp:106 src/snapshot_page/snapshot_page.blp:18 +#: src/snapshot_page/snapshot_page.blp:26 +#: src/snapshot_page/snapshot_page.py:341 +msgid "Snapshots" +msgstr "" + +#: src/main_window/window.blp:119 src/install_page/file_install_dialog.py:34 +#: src/install_page/install_page.blp:17 src/install_page/select_page.blp:5 +#: src/install_page/select_page.blp:10 +msgid "Install Packages" +msgstr "" + +#: src/main_window/window.blp:149 +msgid "_Open Files" +msgstr "" + +#: src/main_window/window.blp:153 +msgid "_Keyboard Shortcuts" +msgstr "" + +#: src/main_window/window.blp:157 +msgid "_About Warehouse" +msgstr "" + +#: src/main_window/window.py:132 +msgid "Unsupported Filetype" +msgstr "" + +#: src/main_window/window.py:133 +msgid "Only .flatpak, .flatpakref, and .flatpakrepo files are supported." +msgstr "" + +#: src/main_window/window.py:141 +msgid "Mixed Filetypes" +msgstr "" + +#: src/main_window/window.py:142 +msgid "Flatpaks and remotes cannot be installed at the same time." +msgstr "" + +#: src/main_window/window.py:151 +msgid "Too Many Remotes" +msgstr "" + +#: src/main_window/window.py:152 +msgid "Only one remote at a time is supported." +msgstr "" + +#: src/main_window/window.py:170 +msgid "Could not open files" +msgstr "" + +#: src/packages_page/filters_page.blp:5 src/packages_page/packages_page.blp:70 +msgid "Filter Packages" +msgstr "" + +#: src/packages_page/filters_page.blp:20 +msgid "Filter by Package Type" +msgstr "" + +#: src/packages_page/filters_page.blp:21 +msgid "Show packages of these types" +msgstr "" + +#: src/packages_page/filters_page.blp:23 +msgid "Applications" +msgstr "" + +#: src/packages_page/filters_page.blp:24 +msgid "Packages that can be opened" +msgstr "" + +#: src/packages_page/filters_page.blp:29 +msgid "Runtimes" +msgstr "" + +#: src/packages_page/filters_page.blp:30 +msgid "Packages that applications depend on" +msgstr "" + +#: src/packages_page/filters_page.blp:37 +msgid "Filter by Remotes" +msgstr "" + +#: src/packages_page/filters_page.blp:38 +msgid "Show packages from selected remotes" +msgstr "" + +#: src/packages_page/filters_page.blp:52 +msgid "Showing packages from all remotes" +msgstr "" + +#: src/packages_page/filters_page.blp:58 +msgid "Enable to show packages from selected remotes" +msgstr "" + +#: src/packages_page/filters_page.blp:71 +msgid "Filter by Runtimes" +msgstr "" + +#: src/packages_page/filters_page.blp:72 +msgid "Show apps using selected runtimes" +msgstr "" + +#: src/packages_page/filters_page.blp:86 +msgid "Showing apps using any runtime" +msgstr "" + +#: src/packages_page/filters_page.blp:92 +msgid "Enable to show apps using selected runtimes" +msgstr "" + +#: src/packages_page/filters_page.blp:113 +#: src/packages_page/packages_page.blp:97 +msgid "Reset Filters" +msgstr "" + +#: src/packages_page/packages_page.blp:65 +#: src/packages_page/packages_page.blp:83 src/remotes_page/remotes_page.blp:30 +#: src/snapshot_page/snapshot_page.blp:35 +msgid "Search Packages" +msgstr "" + +#: src/packages_page/packages_page.blp:75 +#: src/snapshot_page/snapshot_page.blp:45 +msgid "Select Packages" +msgstr "" + +#: src/packages_page/packages_page.blp:93 +msgid "No Packages Match Filters" +msgstr "" + +#: src/packages_page/packages_page.blp:94 +msgid "No installed package matches all of the currently applied filters" +msgstr "" + +#: src/packages_page/packages_page.blp:104 +msgid "No Packages Found" +msgstr "" + +#: src/packages_page/packages_page.blp:105 +msgid "" +"Warehouse cannot see the list of installed packages or your system has no " +"packages installed" +msgstr "" + +#: src/packages_page/packages_page.blp:109 +#: src/remotes_page/remotes_page.blp:153 src/user_data_page/data_subpage.blp:68 +#: src/snapshot_page/new_snapshot_dialog.blp:67 +#: src/snapshot_page/snapshot_page.blp:58 src/install_page/results_page.blp:49 +msgid "No Results Found" +msgstr "" + +#: src/packages_page/packages_page.blp:110 +#: src/remotes_page/remotes_page.blp:154 src/user_data_page/data_subpage.blp:69 +#: src/snapshot_page/new_snapshot_dialog.blp:68 +#: src/snapshot_page/snapshot_page.blp:59 +msgid "Try a different search" +msgstr "" + +#: src/packages_page/packages_page.blp:127 +#: src/user_data_page/user_data_page.blp:81 +#: src/snapshot_page/new_snapshot_dialog.blp:79 +#: src/snapshot_page/snapshot_page.blp:140 +msgid "Select All" +msgstr "" + +#: src/packages_page/packages_page.blp:144 +#: src/packages_page/uninstall_dialog.py:38 +#: src/properties_page/properties_page.blp:88 +msgid "Uninstall" +msgstr "" + +#: src/packages_page/packages_page.blp:173 +msgid "Copy Names" +msgstr "" + +#: src/packages_page/packages_page.blp:177 +msgid "Copy IDs" +msgstr "" + +#: src/packages_page/packages_page.blp:181 +msgid "Copy Refs" +msgstr "" + +#: src/packages_page/packages_page.py:150 +#: src/user_data_page/data_subpage.py:108 +#: src/snapshot_page/new_snapshot_dialog.py:39 +#: src/snapshot_page/snapshot_page.py:341 +msgid "{} Selected" +msgstr "" + +#: src/packages_page/packages_page.py:191 +msgid "Error getting Flatpak '{}'" +msgstr "" + +#: src/packages_page/packages_page.py:229 +msgid "Names" +msgstr "" + +#: src/packages_page/packages_page.py:232 +msgid "IDs" +msgstr "" + +#: src/packages_page/packages_page.py:235 +msgid "Refs" +msgstr "" + +#: src/packages_page/packages_page.py:243 +#: src/properties_page/properties_page.py:272 +msgid "Copied {}" +msgstr "" + +#: src/packages_page/packages_page.py:245 +msgid "Could not copy {}" +msgstr "" + +#: src/packages_page/packages_page.py:277 +msgid "Could not uninstall packages" +msgstr "" + +#: src/packages_page/packages_page.py:279 +msgid "Uninstalled Packages" +msgstr "" + +#: src/packages_page/packages_page.py:335 +msgid "Loading Packages" +msgstr "" + +#: src/packages_page/packages_page.py:335 +#: src/packages_page/packages_page.py:336 +#: src/properties_page/properties_page.py:366 +#: src/remotes_page/remotes_page.py:285 src/remotes_page/remotes_page.py:286 +#: src/user_data_page/user_data_page.py:297 +#: src/snapshot_page/snapshot_page.py:546 src/install_page/install_page.py:94 +#: src/install_page/results_page.py:135 +msgid "This should only take a moment" +msgstr "" + +#: src/packages_page/packages_page.py:336 +msgid "Uninstalling Packages" +msgstr "" + +#: src/packages_page/packages_page.py:338 +msgid "Reinstalling Package" +msgstr "" + +#: src/packages_page/packages_page.py:338 +#: src/packages_page/packages_page.py:340 +#: src/change_version_page/change_version_page.py:119 +#: src/snapshot_page/snapshot_page.py:539 src/install_page/install_page.py:86 msgid "This could take a while" msgstr "" -#: data/ui/downgrade.blp:71 src/app_row_widget.py:299 src/window.py:494 +#: src/packages_page/packages_page.py:340 +msgid "Changing Version" +msgstr "" + +#: src/packages_page/uninstall_dialog.blp:8 +msgid "Keep" +msgstr "" + +#: src/packages_page/uninstall_dialog.blp:9 +msgid "Allows restoring app settings and content" +msgstr "" + +#: src/packages_page/uninstall_dialog.blp:17 +#: src/snapshot_page/snapshot_box.blp:65 src/snapshot_page/snapshot_box.py:114 +#: src/snapshot_page/snapshot_page.py:509 +msgid "Trash" +msgstr "" + +#: src/packages_page/uninstall_dialog.blp:18 +msgid "Send data to the trash" +msgstr "" + +#: src/packages_page/uninstall_dialog.py:30 +msgid "Uninstall {}?" +msgstr "" + +#: src/packages_page/uninstall_dialog.py:31 +msgid "It will not be possible to use {} after removal" +msgstr "" + +#: src/packages_page/uninstall_dialog.py:33 +msgid "Uninstall Packages?" +msgstr "" + +#: src/packages_page/uninstall_dialog.py:34 +msgid "It will not be possible to use these packages after removal" +msgstr "" + +#: src/packages_page/uninstall_dialog.py:41 +msgid "App Settings & Content" +msgstr "" + +#: src/properties_page/properties_page.blp:19 +msgid "Properties Page Unavailable" +msgstr "" + +#: src/properties_page/properties_page.blp:20 +msgid "Cannot show the properties page at this time" +msgstr "" + +#: src/properties_page/properties_page.blp:83 +msgid "Open" +msgstr "" + +#: src/properties_page/properties_page.blp:100 +#: src/properties_page/properties_page.blp:179 +msgid "This package is End Of Life, and will not recieve any security updates" +msgstr "" + +#: src/properties_page/properties_page.blp:122 +#: src/user_data_page/data_box.blp:62 +msgid "Open User Data" +msgstr "" + +#: src/properties_page/properties_page.blp:130 +#: src/user_data_page/data_box.blp:73 +msgid "Trash User Data" +msgstr "" + +#: src/properties_page/properties_page.blp:139 +msgid "Version" +msgstr "" + +#: src/properties_page/properties_page.blp:143 +msgid "Updates Disabled" +msgstr "" + +#: src/properties_page/properties_page.blp:147 +#: src/change_version_page/change_version_page.blp:50 msgid "Disable Updates" msgstr "" -#: data/ui/downgrade.blp:77 +#: src/properties_page/properties_page.blp:148 +msgid "Mask this package so it's never updated" +msgstr "" + +#: src/properties_page/properties_page.blp:157 +#: src/change_version_page/change_version_page.blp:23 +msgid "Change Version" +msgstr "" + +#: src/properties_page/properties_page.blp:158 +msgid "Upgrade or downgrade this package" +msgstr "" + +#: src/properties_page/properties_page.blp:167 +msgid "Installed Size" +msgstr "" + +#: src/properties_page/properties_page.blp:175 +msgid "Runtime" +msgstr "" + +#: src/properties_page/properties_page.blp:188 +msgid "Disable Automactic Removal" +msgstr "" + +#: src/properties_page/properties_page.blp:189 +msgid "Pin this runtime to keep it installed" +msgstr "" + +#: src/properties_page/properties_page.blp:200 +msgid "Package Information" +msgstr "" + +#: src/properties_page/properties_page.blp:203 +msgid "Application ID" +msgstr "" + +#: src/properties_page/properties_page.blp:219 +msgid "Architecture" +msgstr "" + +#: src/properties_page/properties_page.blp:227 +msgid "Branch" +msgstr "" + +#: src/properties_page/properties_page.blp:235 +msgid "License" +msgstr "" + +#: src/properties_page/properties_page.blp:244 +msgid "Installation Information" +msgstr "" + +#: src/properties_page/properties_page.blp:255 +msgid "Origin" +msgstr "" + +#: src/properties_page/properties_page.blp:263 +msgid "Collection" +msgstr "" + +#: src/properties_page/properties_page.blp:271 +msgid "Installation" +msgstr "" + +#: src/properties_page/properties_page.blp:279 +msgid "Commit Information" +msgstr "" + +#: src/properties_page/properties_page.blp:290 +msgid "Parent" +msgstr "" + +#: src/properties_page/properties_page.blp:298 +msgid "Subject" +msgstr "" + +#: src/properties_page/properties_page.blp:306 +msgid "Date" +msgstr "" + +#: src/properties_page/properties_page.py:75 +msgid "{} Properties" +msgstr "" + +#: src/properties_page/properties_page.py:80 +msgid "Properties" +msgstr "" + +#. Apply again +#: src/properties_page/properties_page.py:109 +#: src/user_data_page/user_data_page.py:297 +msgid "Loading User Data" +msgstr "" + +#: src/properties_page/properties_page.py:119 +msgid "No User Data" +msgstr "" + +#: src/properties_page/properties_page.py:129 +#: src/properties_page/properties_page.py:145 +msgid "Could not get properties" +msgstr "" + +#: src/properties_page/properties_page.py:142 +msgid "No version information found" +msgstr "" + +#: src/properties_page/properties_page.py:162 +msgid "Could not open data" +msgstr "" + +#: src/properties_page/properties_page.py:181 +#: src/properties_page/properties_page.py:183 +#: src/properties_page/properties_page.py:238 +#: src/properties_page/properties_page.py:240 src/user_data_page/data_box.py:99 +#: src/user_data_page/user_data_page.py:184 +#: src/user_data_page/user_data_page.py:198 +msgid "Could not trash data" +msgstr "" + +#: src/properties_page/properties_page.py:186 +msgid "Send {}'s User Data to the Trash?" +msgstr "" + +#: src/properties_page/properties_page.py:187 +msgid "Your settings and data for this app will be sent to the trash" +msgstr "" + +#: src/properties_page/properties_page.py:190 +msgid "Trash Data" +msgstr "" + +#: src/properties_page/properties_page.py:199 +msgid "Could not Disable Updates" +msgstr "" + +#: src/properties_page/properties_page.py:199 +msgid "Could not Enable Updates" +msgstr "" + +#: src/properties_page/properties_page.py:205 +msgid "Disabled Updates" +msgstr "" + +#: src/properties_page/properties_page.py:205 +msgid "Enabled Updates" +msgstr "" + +#: src/properties_page/properties_page.py:217 +msgid "Could not Disable Autoremoval" +msgstr "" + +#: src/properties_page/properties_page.py:217 +msgid "Could not Enable Autoremoval" +msgstr "" + +#: src/properties_page/properties_page.py:222 +msgid "Disabled Autoremoval" +msgstr "" + +#: src/properties_page/properties_page.py:222 +msgid "Enabled Autoremoval" +msgstr "" + +#: src/properties_page/properties_page.py:245 +msgid "Could not uninstall" +msgstr "" + +#: src/properties_page/properties_page.py:249 +msgid "Uninstalled {}" +msgstr "" + +#: src/properties_page/properties_page.py:261 +msgid "Openeing {}â€Ļ" +msgstr "" + +#: src/properties_page/properties_page.py:266 +msgid "Could not open {}" +msgstr "" + +#: src/properties_page/properties_page.py:281 +msgid "Reinstalled {}" +msgstr "" + +#: src/properties_page/properties_page.py:307 +msgid "Reinstall {}?" +msgstr "" + +#: src/properties_page/properties_page.py:308 +msgid "" +"This package will be uninstalled, and then reinstalled from the same remote " +"and installation." +msgstr "" + +#: src/properties_page/properties_page.py:311 +#: src/properties_page/properties_page.py:372 +msgid "Reinstall" +msgstr "" + +#: src/properties_page/properties_page.py:328 +msgid "Copied launch command" +msgstr "" + +#: src/properties_page/properties_page.py:330 +msgid "Could not copy launch command" +msgstr "" + +#: src/properties_page/properties_page.py:336 +msgid "Could not show details" +msgstr "" + +#: src/properties_page/properties_page.py:366 +msgid "Loading Properties" +msgstr "" + +#: src/properties_page/properties_page.py:369 +msgid "View Snapshots" +msgstr "" + +#: src/properties_page/properties_page.py:370 +msgid "Copy Launch Command" +msgstr "" + +#: src/properties_page/properties_page.py:371 +msgid "Show Details" +msgstr "" + +#: src/change_version_page/change_version_page.blp:5 +msgid "Change Versions" +msgstr "" + +#: src/change_version_page/change_version_page.blp:56 msgid "Select a Release" msgstr "" -#: data/ui/downgrade.blp:78 +#: src/change_version_page/change_version_page.blp:57 msgid "" "This will uninstall the current release and install the chosen one instead. " "Note that downgrading can cause issues." msgstr "" -#: data/ui/filter.blp:5 src/gtk/help-overlay.blp:19 -msgid "Set Filters" +#: src/change_version_page/change_version_page.py:69 +msgid "Currently Installed Version" msgstr "" -#: data/ui/filter.blp:39 -msgid "Show Apps" +#: src/change_version_page/change_version_page.py:86 +msgid "Could not get versions" msgstr "" -#: data/ui/filter.blp:49 -msgid "Show Runtimes" +#: src/change_version_page/change_version_page.py:93 +msgid "Changed {}'s Version" msgstr "" -#: data/ui/filter.blp:60 -msgid "Filter by Remotes" +#: src/change_version_page/change_version_page.py:117 +msgid "{} Versions" msgstr "" -#: data/ui/filter.blp:65 -msgid "Filter by Runtimes" -msgstr "" - -#: data/ui/filter.blp:73 data/ui/window.blp:179 -msgid "Reset Filters" -msgstr "" - -#: data/ui/orphans.blp:14 data/ui/window.blp:12 -msgid "Search List" -msgstr "" - -#: data/ui/orphans.blp:20 -msgid "Open Data Folder" -msgstr "" - -#: data/ui/orphans.blp:81 data/ui/search_install.blp:171 data/ui/window.blp:98 -msgid "Installing" -msgstr "" - -#: data/ui/orphans.blp:102 -msgid "No Leftover Data" -msgstr "" - -#: data/ui/orphans.blp:103 -msgid "There is no leftover user data" -msgstr "" - -#: data/ui/orphans.blp:108 data/ui/search_install.blp:87 data/ui/window.blp:187 -msgid "No Results Found" -msgstr "" - -#: data/ui/orphans.blp:109 data/ui/search_install.blp:88 data/ui/window.blp:188 -msgid "Try a different search term" -msgstr "" - -#: data/ui/orphans.blp:119 data/ui/window.blp:206 -msgid "Select All" -msgstr "" - -#: data/ui/orphans.blp:124 src/window.py:199 src/window.py:281 -msgid "Trash" -msgstr "" - -#: data/ui/orphans.blp:130 data/ui/search_install.blp:54 -#: src/orphans_window.py:122 src/window.py:839 -msgid "Install" -msgstr "" - -#: data/ui/popular_remotes.blp:18 -msgid "Add Remote" -msgstr "" - -#: data/ui/popular_remotes.blp:19 -msgid "Choose from a list of popular remotes or add a new one" -msgstr "" - -#: data/ui/popular_remotes.blp:45 data/ui/remotes.blp:51 -msgid "Add a Repo File" -msgstr "" - -#: data/ui/popular_remotes.blp:50 data/ui/remotes.blp:56 -msgid "Add a Custom Remote" -msgstr "" - -#: data/ui/properties.blp:7 -msgid "Properties" -msgstr "" - -#: data/ui/properties.blp:73 -msgid "Loading User Data" -msgstr "" - -#: data/ui/properties.blp:78 src/app_row_widget.py:268 -#: src/orphans_window.py:258 -msgid "Open User Data Folder" -msgstr "" - -#: data/ui/properties.blp:90 src/app_row_widget.py:281 -msgid "Trash User Data" -msgstr "" - -#: data/ui/properties.blp:108 -msgid "Show Apps Using This Runtime" -msgstr "" - -#: data/ui/properties.blp:119 -msgid "Runtime" -msgstr "" - -#: data/ui/properties.blp:124 src/app_row_widget.py:156 -msgid "View Properties" -msgstr "" - -#: data/ui/properties.blp:135 src/app_row_widget.py:234 src/window.py:562 -msgid "Copy" -msgstr "" - -#: data/ui/properties.blp:147 -msgid "Show Details in Store" -msgstr "" - -#: data/ui/remotes.blp:5 src/gtk/help-overlay.blp:42 -msgid "Manage Remotes" -msgstr "" - -#: data/ui/remotes.blp:14 -msgid "Refresh List of Remotes" -msgstr "" - -#: data/ui/remotes.blp:23 -msgid "Installed Remotes" -msgstr "" - -#: data/ui/remotes.blp:27 -msgid "Show Disabled" -msgstr "" - -#: data/ui/remotes.blp:41 -msgid "No Remotes Found" -msgstr "" - -#: data/ui/remotes.blp:45 -msgid "Add a Popular Remote" -msgstr "" - -#: data/ui/remotes.blp:49 -msgid "Add Other Remotes" -msgstr "" - -#: data/ui/remotes.blp:79 -msgid "Adding Remote" -msgstr "" - -#: data/ui/remotes.blp:88 data/ui/window.blp:54 data/ui/window.blp:194 -msgid "This should only take a moment" -msgstr "" - -#: data/ui/search_install.blp:14 -msgid "Search Criteria" -msgstr "" - -#: data/ui/search_install.blp:22 -msgid "Choose a Remote to Search" -msgstr "" - -#: data/ui/search_install.blp:36 -msgid "Results" -msgstr "" - -#: data/ui/search_install.blp:71 -msgid "Start Search" -msgstr "" - -#: data/ui/search_install.blp:80 -msgid "Search for Flatpaks" -msgstr "" - -#: data/ui/search_install.blp:82 -msgid "Search for Flatpaks that you want to install" -msgstr "" - -#: data/ui/search_install.blp:108 -msgid "Searching" -msgstr "" - -#: data/ui/search_install.blp:119 -msgid "Too Many Results" -msgstr "" - -#: data/ui/search_install.blp:120 -msgid "Try being more specific with your search" -msgstr "" - -#: data/ui/snapshots.blp:14 -msgid "Open Snapshots Folder" -msgstr "" - -#: data/ui/snapshots.blp:32 -msgid "New Snapshot" -msgstr "" - -#: data/ui/snapshots.blp:83 -msgid "No Snapshots" -msgstr "" - -#: data/ui/snapshots.blp:84 -msgid "" -"Snapshots are backups of the app's user data. They can be reapplied at any " -"time." -msgstr "" - -#: data/ui/window.blp:18 -msgid "Filter List" -msgstr "" - -#: data/ui/window.blp:24 -msgid "Main Menu" -msgstr "" - -#: data/ui/window.blp:31 src/gtk/help-overlay.blp:29 -msgid "Toggle Selection Mode" -msgstr "" - -#: data/ui/window.blp:53 -msgid "Loading Flatpaks" -msgstr "" - -#: data/ui/window.blp:129 -msgid "Uninstalling" -msgstr "" - -#: data/ui/window.blp:157 -msgid "Creating Snapshots" -msgstr "" - -#: data/ui/window.blp:169 -msgid "No Flatpaks Found" -msgstr "" - -#: data/ui/window.blp:170 -msgid "" -"Warehouse cannot see the list of installed Flatpaks or the system has no " -"Flatpaks installed" -msgstr "" - -#: data/ui/window.blp:175 -msgid "No Flatpaks Match Filters" -msgstr "" - -#: data/ui/window.blp:176 -msgid "No installed Flatpak matches all of the currently applied filters" -msgstr "" - -#: data/ui/window.blp:193 -msgid "Refreshing List" -msgstr "" - -#: data/ui/window.blp:212 -msgid "Uninstall Selected Apps" -msgstr "" - -#: data/ui/window.blp:218 -msgid "Send Selected Apps' Data to the Trash" -msgstr "" - -#: data/ui/window.blp:224 -msgid "Open Copy Menu" -msgstr "" - -#: data/ui/window.blp:231 -msgid "Snapshot Selected Apps' Data" -msgstr "" - -#: data/ui/window.blp:241 -msgid "Manage Leftover Dataâ€Ļ" -msgstr "" - -#: data/ui/window.blp:250 -msgid "Manage Remotesâ€Ļ" -msgstr "" - -#: data/ui/window.blp:256 -msgid "Install From Fileâ€Ļ" -msgstr "" - -#: data/ui/window.blp:261 -msgid "Install From The Webâ€Ļ" -msgstr "" - -#: data/ui/window.blp:267 -msgid "Refresh List" -msgstr "" - -#: data/ui/window.blp:271 -msgid "_Keyboard Shortcuts" -msgstr "" - -#: data/ui/window.blp:276 -msgid "_About Warehouse" -msgstr "" - -#: data/ui/window.blp:285 -msgid "Copy Names" -msgstr "" - -#: data/ui/window.blp:290 -msgid "Copy IDs" -msgstr "" - -#: data/ui/window.blp:295 -msgid "Copy Refs" -msgstr "" - -#: src/app_row_widget.py:82 -msgid "Updates Disabled" -msgstr "" - -#: src/app_row_widget.py:87 src/properties_window.py:212 -msgid "{} is masked and will not be updated" -msgstr "" - -#: src/app_row_widget.py:94 -msgid "Auto Removal Disabled" -msgstr "" - -#: src/app_row_widget.py:99 -msgid "" -"{} is pinned and will not be auto removed even when it's required by no app" -msgstr "" - -#: src/app_row_widget.py:106 -msgid "App EOL" -msgstr "" - -#: src/app_row_widget.py:112 src/properties_window.py:195 -msgid "" -"{} has reached its End of Life and will not receive any security updates" -msgstr "" - -#: src/app_row_widget.py:123 -msgid "Runtime EOL" -msgstr "" - -#: src/app_row_widget.py:129 src/properties_window.py:203 -msgid "" -"{}'s runtime has reached its End of Life and will not receive any security " -"updates" -msgstr "" - -#: src/app_row_widget.py:165 src/orphans_window.py:266 -msgid "Select" -msgstr "" - -#: src/app_row_widget.py:176 src/remotes_window.py:227 -msgid "View More" -msgstr "" - -#: src/app_row_widget.py:197 -msgid "Copied name" -msgstr "" - -#: src/app_row_widget.py:201 -msgid "Copy Name" -msgstr "" - -#: src/app_row_widget.py:206 -msgid "Copied ID" -msgstr "" - -#: src/app_row_widget.py:211 -msgid "Copy ID" -msgstr "" - -#: src/app_row_widget.py:216 -msgid "Copied ref" -msgstr "" - -#: src/app_row_widget.py:221 -msgid "Copy Ref" -msgstr "" - -#: src/app_row_widget.py:227 -msgid "Copied launch command" -msgstr "" - -#: src/app_row_widget.py:231 -msgid "Copy Launch Command" -msgstr "" - -#: src/app_row_widget.py:243 -msgid "Opened {}" -msgstr "" - -#: src/app_row_widget.py:246 -msgid "Open" -msgstr "" - -#: src/app_row_widget.py:256 src/window.py:229 src/window.py:302 -msgid "Uninstall" -msgstr "" - -#: src/app_row_widget.py:311 -msgid "Enable Updates" -msgstr "" - -#: src/app_row_widget.py:324 src/window.py:546 -msgid "Disable Auto Removal" -msgstr "" - -#: src/app_row_widget.py:336 -msgid "Enable Auto Removal" -msgstr "" - -#: src/app_row_widget.py:350 -msgid "Manage Snapshots" -msgstr "" - -#: src/common.py:325 src/common.py:399 -msgid "" -"Working on {}\n" -"{} out of {}" -msgstr "" - -#: src/downgrade_window.py:88 -msgid "Commit Hash: {}" -msgstr "" - -#: src/downgrade_window.py:110 -msgid "Could not downgrade {}" -msgstr "" - -#: src/downgrade_window.py:128 -msgid "Downgradingâ€Ļ" -msgstr "" - -#: src/downgrade_window.py:151 -msgid "Downgrade {}" -msgstr "" - -#: src/downgrade_window.py:159 +#: src/change_version_page/change_version_page.py:118 msgid "Ensure that {} will never be updated to a newer version" msgstr "" -#: src/filter_window.py:43 src/filter_window.py:49 -msgid "{} selected" +#: src/change_version_page/change_version_page.py:119 +msgid "Fetching Releases" msgstr "" -#: src/filter_window.py:135 src/orphans_window.py:150 src/remotes_window.py:307 -msgid "User wide" +#: src/change_version_page/change_version_worker.py:66 +#: src/change_version_page/change_version_worker.py:70 +#: src/change_version_page/change_version_worker.py:74 +msgid "Error occurred while changing version" msgstr "" -#: src/filter_window.py:137 src/orphans_window.py:152 src/remotes_window.py:309 -msgid "System wide" +#: src/change_version_page/change_version_worker.py:85 +msgid "Could not cancel version change" msgstr "" -#: src/filter_window.py:139 src/orphans_window.py:154 src/remotes_window.py:311 -msgid "Unknown install type" +#: src/change_version_page/change_version_worker.py:106 +msgid "Could not change version" msgstr "" -#: src/gtk/help-overlay.blp:11 -msgid "App Management" +#: src/change_version_page/change_version_worker.py:106 +msgid "Another package is changing version." msgstr "" -#: src/gtk/help-overlay.blp:14 -msgid "Search" +#: src/remotes_page/add_remote_dialog.blp:5 src/install_page/select_page.blp:27 +msgid "Add a Remote" msgstr "" -#: src/gtk/help-overlay.blp:24 -msgid "Refresh" -msgstr "" - -#: src/gtk/help-overlay.blp:34 -msgid "More Functions" -msgstr "" - -#: src/gtk/help-overlay.blp:37 src/orphans_window.py:30 -msgid "Manage Leftover Data" -msgstr "" - -#: src/gtk/help-overlay.blp:47 -msgid "Install From File" -msgstr "" - -#: src/gtk/help-overlay.blp:52 -msgid "General" -msgstr "" - -#: src/gtk/help-overlay.blp:55 -msgid "Open Menu" -msgstr "" - -#: src/gtk/help-overlay.blp:60 -msgid "Show Shortcuts" -msgstr "" - -#: src/gtk/help-overlay.blp:65 -msgid "Quit" -msgstr "" - -#: src/main.py:149 -msgid "Flatpaks" -msgstr "" - -#. Translators: do one of the following, one per line: Your Name, Your Name , Your Name https://websi.te -#: src/main.py:191 -msgid "translator-credits" -msgstr "" - -#: src/main.py:197 -msgid "Donate" -msgstr "" - -#: src/main.py:199 -msgid "Contributors" -msgstr "" - -#: src/orphans_window.py:80 src/window.py:810 -msgid "Installed successfully" -msgstr "" - -#: src/orphans_window.py:83 -msgid "Could not install some apps" -msgstr "" - -#: src/orphans_window.py:117 -msgid "Attempt to Install?" -msgstr "" - -#: src/orphans_window.py:118 -msgid "Warehouse will attempt to install apps matching the selected data." -msgstr "" - -#: src/orphans_window.py:121 src/orphans_window.py:189 -#: src/properties_window.py:156 src/remotes_window.py:86 -#: src/remotes_window.py:167 src/remotes_window.py:450 -#: src/remotes_window.py:621 src/snapshots_window.py:128 -#: src/snapshots_window.py:234 src/window.py:228 src/window.py:301 -#: src/window.py:446 src/window.py:492 src/window.py:543 src/window.py:645 -#: src/window.py:715 src/window.py:838 -msgid "Cancel" -msgstr "" - -#: src/orphans_window.py:185 -msgid "Trash folders?" -msgstr "" - -#: src/orphans_window.py:185 -msgid "These folders will be sent to the trash." -msgstr "" - -#: src/orphans_window.py:190 -msgid "Continue" -msgstr "" - -#: src/orphans_window.py:198 src/properties_window.py:46 -#: src/snapshots_window.py:245 src/window.py:424 -msgid "Could not open folder" -msgstr "" - -#: src/orphans_window.py:216 -msgid "Could not manage data" -msgstr "" - -#: src/properties_window.py:40 src/remotes_window.py:212 -msgid "Copied {}" -msgstr "" - -#: src/properties_window.py:52 -msgid "Could not show details" -msgstr "" - -#: src/properties_window.py:58 -msgid "User Data" -msgstr "" - -#: src/properties_window.py:69 -msgid "Description" -msgstr "" - -#: src/properties_window.py:84 src/properties_window.py:144 -msgid "No User Data" -msgstr "" - -#: src/properties_window.py:135 -msgid "Could not show properties" -msgstr "" - -#: src/properties_window.py:143 src/window.py:438 -msgid "Trashed user data" -msgstr "" - -#: src/properties_window.py:150 src/window.py:433 -msgid "Could not trash user data" -msgstr "" - -#: src/properties_window.py:154 src/window.py:441 -msgid "Send {}'s User Data to the Trash?" -msgstr "" - -#: src/properties_window.py:158 src/window.py:448 src/window.py:646 -msgid "Trash Data" -msgstr "" - -#: src/remotes_window.py:71 -msgid "Could not remove {}" -msgstr "" - -#: src/remotes_window.py:81 src/remotes_window.py:162 -msgid "Any installed apps from {} will stop receiving updates" -msgstr "" - -#: src/remotes_window.py:84 -msgid "Remove {}?" -msgstr "" - -#: src/remotes_window.py:87 src/remotes_window.py:272 -msgid "Remove" -msgstr "" - -#: src/remotes_window.py:114 -msgid "Could not enable {}" -msgstr "" - -#: src/remotes_window.py:142 -msgid "Could not disable {}" -msgstr "" - -#: src/remotes_window.py:165 -msgid "Disable {}?" -msgstr "" - -#: src/remotes_window.py:168 src/remotes_window.py:263 -msgid "Disable" -msgstr "" - -#: src/remotes_window.py:179 -msgid "Could not view apps" -msgstr "" - -#: src/remotes_window.py:240 -msgid "Set Filter" -msgstr "" - -#: src/remotes_window.py:254 -msgid "Enable" -msgstr "" - -#: src/remotes_window.py:288 -msgid "Copy remote name" -msgstr "" - -#: src/remotes_window.py:302 -msgid "Disabled" -msgstr "" - -#: src/remotes_window.py:339 -msgid "The open source, pay-what-you-want app store from elementary" -msgstr "" - -#: src/remotes_window.py:345 -msgid "Central repository of Flatpak applications" -msgstr "" - -#: src/remotes_window.py:351 -msgid "Beta builds of Flatpak applications" -msgstr "" - -#: src/remotes_window.py:357 -msgid "Flatpaks packaged by Fedora Linux" -msgstr "" - -#: src/remotes_window.py:363 -msgid "The latest beta GNOME Apps and Runtimes" -msgstr "" - -#: src/remotes_window.py:369 -msgid "Beta KDE Apps and Runtimes" -msgstr "" - -#: src/remotes_window.py:375 -msgid "Central repository of the WebKit Developer and Runtime SDK" -msgstr "" - -#: src/remotes_window.py:411 src/remotes_window.py:573 -msgid "Could not add {}" -msgstr "" - -#: src/remotes_window.py:448 -msgid "Add Flatpak Remote" -msgstr "" - -#: src/remotes_window.py:451 src/remotes_window.py:622 +#: src/remotes_page/add_remote_dialog.blp:22 msgid "Add" msgstr "" -#: src/remotes_window.py:505 src/remotes_window.py:630 +#: src/remotes_page/add_remote_dialog.blp:40 +msgid "Title" +msgstr "" + +#: src/remotes_page/add_remote_dialog.blp:43 +#: src/user_data_page/user_data_page.blp:190 msgid "Name" msgstr "" -#: src/remotes_window.py:509 -msgid "URL" +#: src/remotes_page/add_remote_dialog.blp:46 +msgid "Repo URL" msgstr "" -#: src/remotes_window.py:521 src/remotes_window.py:633 src/window.py:847 -msgid "User" +#: src/remotes_page/add_remote_dialog.py:54 +msgid "Could not add remote" msgstr "" -#: src/remotes_window.py:521 src/remotes_window.py:633 -msgid "Remote will be available to only you" +#: src/remotes_page/add_remote_dialog.py:57 +msgid "Added {}" msgstr "" -#: src/remotes_window.py:529 src/remotes_window.py:636 src/window.py:850 -msgid "System" +#. Apply +#: src/remotes_page/add_remote_dialog.py:107 +msgid "remote" msgstr "" -#: src/remotes_window.py:530 src/remotes_window.py:637 -msgid "Remote will be available to every user on the system" +#: src/remotes_page/remote_row.blp:21 +msgid "Set a Filter for this Remote" msgstr "" -#: src/remotes_window.py:569 -msgid "{} successfully added" +#: src/remotes_page/remote_row.blp:29 +msgid "More Actions" msgstr "" -#: src/remotes_window.py:619 -msgid "Add {}?" +#: src/remotes_page/remote_row.blp:37 +msgid "Copy Title" msgstr "" -#: src/remotes_window.py:667 +#: src/remotes_page/remote_row.blp:41 +msgid "Copy Name" +msgstr "" + +#: src/remotes_page/remote_row.blp:45 +msgid "Enable" +msgstr "" + +#: src/remotes_page/remote_row.blp:49 src/remotes_page/remote_row.py:123 +msgid "Disable" +msgstr "" + +#: src/remotes_page/remote_row.blp:53 src/remotes_page/remotes_page.py:190 +msgid "Remove" +msgstr "" + +#: src/remotes_page/remote_row.py:24 src/remotes_page/remote_row.py:44 +msgid "Could not enable remote" +msgstr "" + +#: src/remotes_page/remote_row.py:24 +msgid "Remote is already enabled" +msgstr "" + +#: src/remotes_page/remote_row.py:51 +msgid "Enabled remote" +msgstr "" + +#: src/remotes_page/remote_row.py:75 src/remotes_page/remote_row.py:153 +msgid "Remote is Disabled" +msgstr "" + +#: src/remotes_page/remote_row.py:77 +msgid "Disabled remote" +msgstr "" + +#: src/remotes_page/remote_row.py:95 src/remotes_page/remote_row.py:107 +#: src/remotes_page/remote_row.py:111 +msgid "Could not disable remote" +msgstr "" + +#: src/remotes_page/remote_row.py:95 +msgid "Remote is already disabled" +msgstr "" + +#: src/remotes_page/remote_row.py:121 +msgid "Disable {}?" +msgstr "" + +#: src/remotes_page/remote_row.py:121 src/remotes_page/remotes_page.py:188 +msgid "Any installed apps from {} will stop receiving updates" +msgstr "" + +#: src/remotes_page/remote_row.py:133 +msgid "Copied title" +msgstr "" + +#: src/remotes_page/remote_row.py:136 +msgid "Copied name" +msgstr "" + +#: src/remotes_page/remotes_page.blp:5 +msgid "Manage Remotes" +msgstr "" + +#: src/remotes_page/remotes_page.blp:40 +msgid "Search Remotes" +msgstr "" + +#: src/remotes_page/remotes_page.blp:47 +msgid "Current Remotes" +msgstr "" + +#: src/remotes_page/remotes_page.blp:48 +msgid "Remotes available on your system" +msgstr "" + +#: src/remotes_page/remotes_page.blp:55 +msgid "Show Disabled" +msgstr "" + +#: src/remotes_page/remotes_page.blp:75 +msgid "No Enabled Remotes" +msgstr "" + +#: src/remotes_page/remotes_page.blp:81 +msgid "You only have disabled remotes on this system" +msgstr "" + +#: src/remotes_page/remotes_page.blp:107 +msgid "No Remotes Found" +msgstr "" + +#: src/remotes_page/remotes_page.blp:113 +msgid "" +"Warehouse cannot see the current remotes or your system has no remotes added" +msgstr "" + +#: src/remotes_page/remotes_page.blp:126 +msgid "Add Popular Remotes" +msgstr "" + +#: src/remotes_page/remotes_page.blp:127 +msgid "Add new remotes to get more software" +msgstr "" + +#: src/remotes_page/remotes_page.blp:131 +msgid "Add Other Remotes" +msgstr "" + +#: src/remotes_page/remotes_page.blp:134 +msgid "Add a Repo File" +msgstr "" + +#: src/remotes_page/remotes_page.blp:135 +msgid "Open a downloaded repo file to add" +msgstr "" + +#: src/remotes_page/remotes_page.blp:143 +msgid "Add a Custom Remote" +msgstr "" + +#: src/remotes_page/remotes_page.blp:144 +msgid "Manually enter new remote details" +msgstr "" + +#: src/remotes_page/remotes_page.py:32 +msgid "The open source, pay-what-you-want app store from elementary" +msgstr "" + +#: src/remotes_page/remotes_page.py:38 +msgid "Central repository of Flatpak applications" +msgstr "" + +#: src/remotes_page/remotes_page.py:44 +msgid "Beta builds of Flatpak applications" +msgstr "" + +#: src/remotes_page/remotes_page.py:50 +msgid "Flatpaks packaged by Fedora Linux" +msgstr "" + +#: src/remotes_page/remotes_page.py:56 +msgid "The latest beta GNOME Apps and Runtimes" +msgstr "" + +#: src/remotes_page/remotes_page.py:62 +msgid "Central repository of the WebKit Developer and Runtime SDK" +msgstr "" + +#: src/remotes_page/remotes_page.py:152 +msgid "Showing all packages from {}" +msgstr "" + +#: src/remotes_page/remotes_page.py:173 +msgid "Could not remove remote" +msgstr "" + +#: src/remotes_page/remotes_page.py:180 +msgid "Removed {}" +msgstr "" + +#: src/remotes_page/remotes_page.py:188 +msgid "Remove {}?" +msgstr "" + +#: src/remotes_page/remotes_page.py:224 src/remotes_page/remotes_page.py:234 +#: src/remotes_page/remotes_page.py:236 +msgid "Could not open file" +msgstr "" + +#: src/remotes_page/remotes_page.py:239 msgid "Flatpak Repos" msgstr "" -#: src/snapshots_window.py:48 -msgid "There is no User Data to Snapshot" +#. Appply +#: src/remotes_page/remotes_page.py:285 +msgid "Adding Remote" msgstr "" -#: src/snapshots_window.py:87 -msgid "Version {}" +#: src/remotes_page/remotes_page.py:286 +msgid "Loading Remotes" msgstr "" -#: src/snapshots_window.py:95 src/snapshots_window.py:236 -msgid "Apply Snapshot" +#: src/user_data_page/data_box.blp:56 +msgid "Copy Path" msgstr "" -#: src/snapshots_window.py:101 src/snapshots_window.py:130 -msgid "Trash Snapshot" +#: src/user_data_page/data_box.blp:68 +msgid "Attempt to Install" msgstr "" -#: src/snapshots_window.py:121 +#: src/user_data_page/data_box.py:58 +msgid "Copied data path" +msgstr "" + +#: src/user_data_page/data_box.py:60 +msgid "Could not copy data path" +msgstr "" + +#: src/user_data_page/data_box.py:65 +msgid "Opened data folder" +msgstr "" + +#: src/user_data_page/data_box.py:67 src/snapshot_page/snapshot_page.py:246 +#: src/snapshot_page/snapshots_list_page.py:77 +msgid "Could not open folder" +msgstr "" + +#: src/user_data_page/data_box.py:113 +msgid "Trash {}'s Data?" +msgstr "" + +#: src/user_data_page/data_box.py:113 +msgid "{}'s data will be sent to the trash" +msgstr "" + +#: src/user_data_page/data_box.py:115 src/user_data_page/user_data_page.py:211 +#: src/snapshot_page/snapshot_page.py:468 +msgid "Continue" +msgstr "" + +#: src/user_data_page/data_subpage.blp:13 +msgid "No Title Set" +msgstr "" + +#: src/user_data_page/data_subpage.blp:28 +msgid "Loading Sizeâ€Ļ" +msgstr "" + +#: src/user_data_page/data_subpage.py:233 +msgid "No Active Data" +msgstr "" + +#: src/user_data_page/data_subpage.py:234 +msgid "" +"Warehouse cannot see any active user data or your system has no active user " +"data present" +msgstr "" + +#: src/user_data_page/data_subpage.py:237 +msgid "No Leftover Data" +msgstr "" + +#: src/user_data_page/data_subpage.py:238 +msgid "There is no leftover user data" +msgstr "" + +#: src/user_data_page/user_data_page.blp:43 +#: src/user_data_page/user_data_page.blp:64 +msgid "Search User Data" +msgstr "" + +#: src/user_data_page/user_data_page.blp:49 +msgid "Sort User Data" +msgstr "" + +#: src/user_data_page/user_data_page.blp:54 +msgid "Select User Data" +msgstr "" + +#: src/user_data_page/user_data_page.blp:109 +#: src/user_data_page/user_data_page.blp:148 +msgid "Move to Trash" +msgstr "" + +#: src/user_data_page/user_data_page.blp:120 +#: src/snapshot_page/snapshot_page.blp:159 +msgid "More" +msgstr "" + +#: src/user_data_page/user_data_page.blp:169 +msgid "Ascending" +msgstr "" + +#: src/user_data_page/user_data_page.blp:177 +msgid "Descending" +msgstr "" + +#: src/user_data_page/user_data_page.blp:198 +msgid "ID" +msgstr "" + +#: src/user_data_page/user_data_page.blp:206 +msgid "Size" +msgstr "" + +#: src/user_data_page/user_data_page.py:76 +#: src/user_data_page/user_data_page.py:78 +msgid "Loading Size" +msgstr "" + +#: src/user_data_page/user_data_page.py:152 +msgid "Could not copy paths" +msgstr "" + +#: src/user_data_page/user_data_page.py:152 +#: src/user_data_page/user_data_page.py:198 +msgid "No boxes were selected" +msgstr "" + +#: src/user_data_page/user_data_page.py:155 +msgid "Copied paths" +msgstr "" + +#: src/user_data_page/user_data_page.py:186 +msgid "Trashed data" +msgstr "" + +#: src/user_data_page/user_data_page.py:209 +msgid "Trash Data?" +msgstr "" + +#: src/user_data_page/user_data_page.py:209 +msgid "Data will be sent to the trash" +msgstr "" + +#: src/user_data_page/user_data_page.py:247 +#: src/user_data_page/user_data_page.py:270 +msgid "Active Data" +msgstr "" + +#: src/user_data_page/user_data_page.py:248 +#: src/user_data_page/user_data_page.py:276 +msgid "Leftover Data" +msgstr "" + +#: src/snapshot_page/new_snapshot_dialog.blp:21 +#: src/snapshot_page/new_snapshot_dialog.blp:37 +msgid "Search Apps" +msgstr "" + +#: src/snapshot_page/new_snapshot_dialog.blp:26 +msgid "Create" +msgstr "" + +#: src/snapshot_page/new_snapshot_dialog.py:111 +msgid "Creating Snapshot" +msgstr "" + +#: src/snapshot_page/new_snapshot_dialog.py:193 +msgid "Name these Snapshots" +msgstr "" + +#: src/snapshot_page/new_snapshot_dialog.py:197 +#: src/snapshot_page/snapshot_page.blp:40 +#: src/snapshot_page/snapshot_page.blp:190 +#: src/snapshot_page/snapshots_list_page.blp:41 +msgid "New Snapshot" +msgstr "" + +#: src/snapshot_page/new_snapshot_dialog.py:201 +msgid "Name this Snapshot" +msgstr "" + +#: src/snapshot_page/snapshot_box.blp:15 src/snapshot_page/snapshot_box.py:58 +msgid "No Name Set" +msgstr "" + +#: src/snapshot_page/snapshot_box.blp:23 +msgid "No date found" +msgstr "" + +#: src/snapshot_page/snapshot_box.blp:30 +msgid "No version found" +msgstr "" + +#: src/snapshot_page/snapshot_box.blp:46 src/snapshot_page/snapshot_box.py:156 +msgid "Apply" +msgstr "" + +#: src/snapshot_page/snapshot_box.blp:55 +msgid "Rename" +msgstr "" + +#: src/snapshot_page/snapshot_box.blp:84 +msgid "Rename Snapshot?" +msgstr "" + +#: src/snapshot_page/snapshot_box.blp:94 +msgid "Confirm Rename" +msgstr "" + +#: src/snapshot_page/snapshot_box.py:33 src/snapshot_page/snapshot_box.py:45 +#: src/snapshot_page/snapshot_box.py:61 +msgid "Could not write data" +msgstr "" + +#: src/snapshot_page/snapshot_box.py:98 msgid "Could not trash snapshot" msgstr "" -#: src/snapshots_window.py:125 +#: src/snapshot_page/snapshot_box.py:102 +msgid "Trashed snapshot" +msgstr "" + +#: src/snapshot_page/snapshot_box.py:112 msgid "Trash Snapshot?" msgstr "" -#: src/snapshots_window.py:126 -msgid "This snapshot and its contents will be sent to the trash." +#: src/snapshot_page/snapshot_box.py:112 +msgid "This snapshot will be sent to the trash" msgstr "" -#: src/snapshots_window.py:148 -msgid "Could not create snapshot" +#: src/snapshot_page/snapshot_box.py:142 +msgid "Applying Snapshot" msgstr "" -#: src/snapshots_window.py:157 -msgid "Creating Snapshotâ€Ļ" -msgstr "" - -#: src/snapshots_window.py:191 src/snapshots_window.py:208 -#: src/snapshots_window.py:215 -msgid "Could not apply snapshot" -msgstr "" - -#: src/snapshots_window.py:194 -msgid "Snapshot applied" -msgstr "" - -#: src/snapshots_window.py:221 -msgid "Applying Snapshotâ€Ļ" -msgstr "" - -#: src/snapshots_window.py:229 +#: src/snapshot_page/snapshot_box.py:152 msgid "Apply Snapshot?" msgstr "" -#: src/snapshots_window.py:230 -msgid "Applying this snapshot will trash any current user data for {}." +#: src/snapshot_page/snapshot_box.py:153 +msgid "Any current user data for this app will be trashed" msgstr "" -#. Window stuffs -#: src/snapshots_window.py:280 +#: src/snapshot_page/snapshot_box.py:182 +msgid "Version: {}" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:53 +msgid "Search Snapshots" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:70 +msgid "Active Snapshots" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:81 +msgid "Snapshots of installed apps" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:99 +msgid "Leftover Snapshots" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:110 +msgid "Snapshots of apps that are no longer installed" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:177 +msgid "Open Snapshots Folder" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:182 +msgid "No Snapshots" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:183 +msgid "Create a Snapshot to save the state of any Flatpak application" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:219 +msgid "Snapshot Apps" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:223 +msgid "Install Apps" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:227 +msgid "Apply Snapshots" +msgstr "" + +#: src/snapshot_page/snapshot_page.blp:231 +msgid "Trash Snapshots" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:100 +msgid "Could not load Snapshots" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:187 +msgid "Showing snapshots for {}" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:191 +msgid "No snapshots for {}" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:191 +msgid "New" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:235 +#: src/snapshot_page/snapshot_page.py:237 +#: src/snapshot_page/snapshots_list_page.py:60 +msgid "No Data Found to Snapshot" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:244 +#: src/snapshot_page/snapshots_list_page.py:75 +msgid "Opened snapshots folder" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:372 +msgid "Copied Snapshot Paths" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:381 +msgid "No apps in your selection can be snapshotted" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:457 +msgid "Applying Snapshots" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:463 +msgid "No snapshots to extract" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:463 +msgid "No snapshots were found to extract" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:466 +msgid "Apply These Snapshots?" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:466 +msgid "" +"This will trash the current apps' user data, and apply their newest snapshot" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:502 +msgid "Trashed snapshots" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:504 +msgid "Could not trash snapshots" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:507 +msgid "Trash Snapshots?" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:507 +msgid "These apps' snapshots will be sent to the trash" +msgstr "" + +#: src/snapshot_page/snapshot_page.py:546 +msgid "Loading Snapshots" +msgstr "" + +#: src/snapshot_page/snapshots_list_page.blp:5 +msgid "Snapshots List" +msgstr "" + +#: src/snapshot_page/snapshots_list_page.blp:13 +msgid "Open Snapshots Folder for this App" +msgstr "" + +#: src/snapshot_page/snapshots_list_page.py:30 +#: src/snapshot_page/snapshots_list_page.py:51 +msgid "App not Installed" +msgstr "" + +#: src/snapshot_page/snapshots_list_page.py:54 msgid "{} Snapshots" msgstr "" -#: src/window.py:111 -msgid "Uninstalled successfully" +#: src/snapshot_page/tar_worker.py:81 +msgid "Error in snapshot handling" msgstr "" -#: src/window.py:114 -msgid "Could not uninstall some apps" +#: src/install_page/file_install_dialog.blp:33 +msgid "Review Selection" msgstr "" -#: src/window.py:170 -msgid "Uninstall Selected Apps?" +#. self.packages_group.set_title(_("Review Packages")) +#: src/install_page/file_install_dialog.py:36 +msgid "The following packages will be installed" msgstr "" -#: src/window.py:171 -msgid "It will not be possible to use these apps after removal." +#: src/install_page/file_install_dialog.py:39 +msgid "Install a Package" msgstr "" -#: src/window.py:191 src/window.py:273 -msgid "App Settings & Data" +#. self.packages_group.set_title(_("Review Package")) +#: src/install_page/file_install_dialog.py:41 +msgid "The following package will be installed" msgstr "" -#: src/window.py:195 src/window.py:277 -msgid "Keep" +#: src/install_page/file_install_dialog.py:42 +msgid "package" msgstr "" -#: src/window.py:196 -msgid "Allow restoring these apps' settings and content" +#: src/install_page/install_page.blp:41 +msgid "Select Source" msgstr "" -#: src/window.py:200 -msgid "Send these apps' settings and content to the trash" +#: src/install_page/install_page.blp:49 src/install_page/install_page.blp:98 +#: src/install_page/pending_page.blp:5 +msgid "Pending Packages" msgstr "" -#: src/window.py:236 -msgid "Cannot uninstall while already uninstalling" +#: src/install_page/install_page.py:49 +msgid "Installed Packages" msgstr "" -#: src/window.py:265 -msgid "Uninstall {}?" +#: src/install_page/install_page.py:66 +msgid "{} Pending Package" msgstr "" -#: src/window.py:266 -msgid "It will not be possible to use {} after removal." +#: src/install_page/install_page.py:71 +msgid "{} Pending Packages" msgstr "" -#: src/window.py:278 -msgid "Allow restoring this app's settings and content" +#. Extra Object Creation +#: src/install_page/install_page.py:86 +msgid "Installing Packages" msgstr "" -#: src/window.py:282 -msgid "Send this app's settings and content to the trash" +#: src/install_page/install_page.py:94 +msgid "Loading Installation Options" msgstr "" -#: src/window.py:444 -msgid "Your files and data for this app will be sent to the trash." +#: src/install_page/pending_page.blp:13 src/install_page/select_page.blp:37 +msgid "Add Packages" msgstr "" -#: src/window.py:463 -msgid "Could not disable updates for {}" +#: src/install_page/pending_page.blp:14 +msgid "Packages queued to install will show up here" msgstr "" -#: src/window.py:485 -msgid "Disable Updates for {}?" +#: src/install_page/pending_page.py:34 +msgid "Remove All" msgstr "" -#: src/window.py:489 -msgid "" -"This will mask {} ensuring it will never recieve any feature or security " -"updates." +#: src/install_page/result_row.blp:8 src/install_page/result_row.py:43 +msgid "Add Package to Queue" msgstr "" -#: src/window.py:511 -msgid "Could not enable auto removal" +#: src/install_page/result_row.py:47 +msgid "Package has been Added to Queue" msgstr "" -#: src/window.py:514 -msgid "Could not disable auto removal" +#: src/install_page/result_row.py:51 +msgid "Remove Package from Queue" msgstr "" -#: src/window.py:537 -msgid "Disable Automatic Removal for {}?" +#: src/install_page/result_row.py:55 +msgid "This Package is Already Installed" msgstr "" -#: src/window.py:540 -msgid "" -"This will pin {} ensuring it well never be removed automatically, even if no " -"app depends on it." +#: src/install_page/results_page.blp:5 +msgid "Search a Remote" msgstr "" -#: src/window.py:560 -msgid "Could not Run App" +#. hexpand: true; +#: src/install_page/results_page.blp:20 +msgid "Search for Packages" msgstr "" -#: src/window.py:568 -msgid "OK" +#: src/install_page/results_page.blp:26 +msgid "Search for Flatpaks" msgstr "" -#: src/window.py:620 -msgid "{} has no data to trash" +#: src/install_page/results_page.blp:27 +msgid "Search for Flatpaks you want to install" msgstr "" -#: src/window.py:626 -msgid "Could not trash {}'s data" +#: src/install_page/results_page.blp:31 +msgid "Too Many Results" msgstr "" -#: src/window.py:641 -msgid "Trash Selected Apps' User Data?" +#: src/install_page/results_page.blp:32 +msgid "Try being more specific with your search" msgstr "" -#: src/window.py:642 -msgid "Your files and data for these apps will be sent to the trash." +#: src/install_page/results_page.blp:50 +msgid "Try a different search term" msgstr "" -#: src/window.py:692 -msgid "Could not snapshot some apps" +#: src/install_page/results_page.py:50 +msgid "Search {}" msgstr "" -#: src/window.py:709 -msgid "Create Snapshots?" +#: src/install_page/results_page.py:135 +msgid "Searching" msgstr "" -#: src/window.py:711 -msgid "" -"Snapshots are backups of the app's user data. They can be reapplied at any " -"time. This could take a while." +#: src/install_page/select_page.blp:19 +msgid "Search in a Remote" msgstr "" -#: src/window.py:716 -msgid "Create Snapshots" +#: src/install_page/select_page.blp:20 +msgid "Choose a remote to search for new packages" msgstr "" -#: src/window.py:771 -msgid "Copied selected app names" +#: src/install_page/select_page.blp:23 +msgid "Online Searches Disabled" msgstr "" -#: src/window.py:786 -msgid "Copied selected app IDs" +#: src/install_page/select_page.blp:24 +msgid "Your system has no remotes added to search from" msgstr "" -#: src/window.py:801 -msgid "Copied selected app refs" +#: src/install_page/select_page.blp:28 +msgid "Add a remote to your system to enable online searching" msgstr "" -#: src/window.py:812 -msgid "Could not install app" +#: src/install_page/select_page.blp:38 +msgid "Install packages from files on your system" msgstr "" -#: src/window.py:836 -msgid "Install {}?" +#: src/install_page/select_page.py:60 +msgid "No files were found to install" msgstr "" -#: src/window.py:847 -msgid "The app will be available to only you" -msgstr "" - -#: src/window.py:851 -msgid "The app will be available to every user on the system" -msgstr "" - -#: src/window.py:881 -msgid "File type not supported" +#: src/install_page/select_page.py:70 +msgid "Flatpaks" msgstr "" diff --git a/src/app_row_widget.py b/src/app_row_widget.py deleted file mode 100644 index 716322d..0000000 --- a/src/app_row_widget.py +++ /dev/null @@ -1,381 +0,0 @@ -import os -import pathlib -import subprocess -import re - -from gi.repository import Adw, Gdk, Gio, GLib, Gtk -from .properties_window import PropertiesWindow -from .downgrade_window import DowngradeWindow -from .snapshots_window import SnapshotsWindow -from .filter_window import FilterWindow -from .common import myUtils - - -class AppRow(Adw.ActionRow): - def set_selectable(self, is_selectable): - self.tickbox.set_active(False) - self.tickbox.set_visible(is_selectable) - self.row_menu.set_visible(not is_selectable) - self.set_activatable(is_selectable) - - def set_is_visible(self, is_visible): - self.set_visible(is_visible) - self.set_selectable(False) - - def info_button_show_or_hide(self): - self.info_button.set_visible(False) - - if self.mask_label.get_visible() == True: - self.info_button.set_visible(True) - return - - if self.pin_label.get_visible() == True: - self.info_button.set_visible(True) - return - - if self.eol_app_label.get_visible() == True: - self.info_button.set_visible(True) - return - - if self.eol_runtime_label.get_visible() == True: - self.info_button.set_visible(True) - return - - def set_masked(self, is_masked): - self.mask_label.set_visible(is_masked) - self.info_button_show_or_hide() - - def __init__(self, parent_window, host_flatpaks, index, **kwargs): - super().__init__(**kwargs) - self.my_utils = myUtils(parent_window) - - current_flatpak = host_flatpaks[index] - - self.index = index - self.app_name = current_flatpak[0] - self.app_id = current_flatpak[2] - self.app_version = current_flatpak[3] - self.origin_remote = current_flatpak[6] - self.install_type = current_flatpak[7] - self.app_ref = current_flatpak[8] - self.dependent_runtime = current_flatpak[13] - - self.set_title(self.app_name) - self.set_subtitle(self.app_id) - self.add_prefix(self.my_utils.find_app_icon(self.app_id)) - - self.is_runtime = False - if len(current_flatpak[13]) == 0: - self.is_runtime = True - - info_box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - valign=Gtk.Align.CENTER, - halign=Gtk.Align.CENTER, - hexpand=True, - vexpand=True, - spacing=6, - ) - - # justify=Gtk.Justification.RIGHT - self.mask_label = Gtk.Label( - label=_("Updates Disabled"), - visible=False, - hexpand=True, - wrap=True, - valign=Gtk.Align.CENTER, - tooltip_text=_("{} is masked and will not be updated").format( - self.app_name - ), - ) - self.mask_label.add_css_class("warning") - - self.pin_label = Gtk.Label( - label=_("Auto Removal Disabled"), - visible=False, - hexpand=True, - wrap=True, - valign=Gtk.Align.CENTER, - tooltip_text=_("{} is pinned and will not be auto removed even when it's required by no app").format( - self.app_name - ) - ) - self.pin_label.add_css_class("warning") - - self.eol_app_label = Gtk.Label( - label=_("App EOL"), - visible=False, - hexpand=True, - wrap=True, - valign=Gtk.Align.CENTER, - tooltip_text=_( - "{} has reached its End of Life and will not receive any security updates" - ).format(self.app_name), - ) - self.eol_app_label.add_css_class("error") - info_box.append(self.eol_app_label) - if "eol" in parent_window.host_flatpaks[index][12]: - # EOL = End Of Life, meaning the app will not be updated - # justify=Gtk.Justification.RIGHT - self.eol_app_label.set_visible(True) - - self.eol_runtime_label = Gtk.Label( - label=_("Runtime EOL"), - visible=False, - hexpand=True, - wrap=True, - valign=Gtk.Align.CENTER, - tooltip_text=_( - "{}'s runtime has reached its End of Life and will not receive any security updates" - ).format(self.app_name), - ) - self.eol_runtime_label.add_css_class("error") - info_box.append(self.eol_runtime_label) - if current_flatpak[13] in parent_window.eol_list: - # EOL = End Of Life, meaning the runtime will not be updated - # justify=Gtk.Justification.RIGHT - self.eol_runtime_label.set_visible(True) - - info_pop = Gtk.Popover() - info_pop.set_child(info_box) - self.info_button = Gtk.MenuButton( - visible=False, - valign=Gtk.Align.CENTER, - popover=info_pop, - icon_name="software-update-urgent-symbolic", - ) - self.info_button.add_css_class("flat") - - info_box.append(self.mask_label) - info_box.append(self.pin_label) - self.add_suffix(self.info_button) - - properties_button = Gtk.Button( - icon_name="info-symbolic", - valign=Gtk.Align.CENTER, - tooltip_text=_("View Properties"), - ) - properties_button.add_css_class("flat") - properties_button.connect( - "clicked", lambda *_: PropertiesWindow(index, host_flatpaks, parent_window) - ) - self.add_suffix(properties_button) - - self.tickbox = Gtk.CheckButton( - visible=False, tooltip_text=_("Select") - ) # visible=self.in_batch_mode - self.tickbox.add_css_class("selection-mode") - self.tickbox.connect("toggled", parent_window.row_select_handler) - self.add_suffix(self.tickbox) - self.set_activatable_widget(self.tickbox) - self.set_activatable(False) - - self.row_menu = Gtk.MenuButton( - icon_name="view-more-symbolic", - valign=Gtk.Align.CENTER, - tooltip_text=_("View More"), - ) - self.row_menu.add_css_class("flat") - row_menu_model = Gio.Menu() - copy_menu_model = Gio.Menu() - advanced_menu_model = Gio.Menu() - self.add_suffix(self.row_menu) - - self.is_pinned = False - - if "user" in self.install_type: - if f"runtime/{self.app_ref}" in parent_window.user_pins: - self.is_pinned = True - - if "system" in self.install_type: - if f"runtime/{self.app_ref}" in parent_window.system_pins: - self.is_pinned = True - - parent_window.create_action( - ("copy-name" + str(index)), - lambda *_, name=self.app_name, toast=_( - "Copied name" - ): parent_window.copy_item(name, toast), - ) - copy_menu_model.append_item( - Gio.MenuItem.new(_("Copy Name"), f"win.copy-name{index}") - ) - - parent_window.create_action( - ("copy-id" + str(index)), - lambda *_, id=self.app_id, toast=_("Copied ID"): parent_window.copy_item( - id, toast - ), - ) - copy_menu_model.append_item( - Gio.MenuItem.new(_("Copy ID"), f"win.copy-id{index}") - ) - - parent_window.create_action( - ("copy-ref" + str(index)), - lambda *_, ref=self.app_ref, toast=_("Copied ref"): parent_window.copy_item( - ref, toast - ), - ) - copy_menu_model.append_item( - Gio.MenuItem.new(_("Copy Ref"), f"win.copy-ref{index}") - ) - - parent_window.create_action( - ("copy-command" + str(index)), - lambda *_, ref=self.app_ref, toast=_( - "Copied launch command" - ): parent_window.copy_item(f"flatpak run {ref}", toast), - ) - copy_menu_model.append_item( - Gio.MenuItem.new(_("Copy Launch Command"), f"win.copy-command{index}") - ) - - row_menu_model.append_submenu(_("Copy"), copy_menu_model) - - if ( - "runtime" not in parent_window.host_flatpaks[index][12] - and self.app_id != "io.github.flattool.Warehouse" - ): - parent_window.create_action( - ("run" + str(index)), - lambda *_a, ref=self.app_ref, name=self.app_name: parent_window.run_app_thread( - ref, _("Opened {}").format(name) - ), - ) - run_item = Gio.MenuItem.new(_("Open"), f"win.run{index}") - row_menu_model.append_item(run_item) - - if self.app_id != "io.github.flattool.Warehouse": - parent_window.create_action( - ("uninstall" + str(index)), - lambda *_: parent_window.uninstall_button_handler( - self, self.app_name, self.app_ref, self.app_id - ), - ) - uninstall_item = Gio.MenuItem.new(_("Uninstall"), f"win.uninstall{index}") - row_menu_model.append_item(uninstall_item) - - data_menu_model = Gio.Menu() - - parent_window.create_action( - ("open-data" + str(index)), - lambda *_, path=( - parent_window.user_data_path + self.app_id - ): parent_window.open_data_folder(path), - ) - open_data_item = Gio.MenuItem.new( - _("Open User Data Folder"), f"win.open-data{index}" - ) - open_data_item.set_attribute_value( - "hidden-when", GLib.Variant.new_string("action-disabled") - ) - data_menu_model.append_item(open_data_item) - - parent_window.create_action( - ("trash" + str(index)), - lambda *_, name=self.app_name, id=self.app_id, index=index: parent_window.trash_data( - name, id, index - ), - ) - trash_item = Gio.MenuItem.new(_("Trash User Data"), f"win.trash{index}") - trash_item.set_attribute_value( - "hidden-when", GLib.Variant.new_string("action-disabled") - ) - data_menu_model.append_item(trash_item) - - row_menu_model.append_section(None, data_menu_model) - - if not os.path.exists(parent_window.user_data_path + self.app_id): - parent_window.lookup_action(f"open-data{self.index}").set_enabled(False) - parent_window.lookup_action(f"trash{self.index}").set_enabled(False) - - parent_window.create_action( - ("mask" + str(index)), - lambda *_, id=self.app_id, type=self.install_type, index=index: parent_window.mask_flatpak( - self - ), - ) - mask_item = Gio.MenuItem.new(_("Disable Updates"), f"win.mask{index}") - mask_item.set_attribute_value( - "hidden-when", GLib.Variant.new_string("action-disabled") - ) - advanced_menu_model.append_item(mask_item) - - parent_window.create_action( - ("unmask" + str(index)), - lambda *_, id=self.app_id, type=self.install_type, index=index: parent_window.mask_flatpak( - self - ), - ) - unmask_item = Gio.MenuItem.new(_("Enable Updates"), f"win.unmask{index}") - unmask_item.set_attribute_value( - "hidden-when", GLib.Variant.new_string("action-disabled") - ) - advanced_menu_model.append_item(unmask_item) - - if "runtime" in parent_window.host_flatpaks[index][12]: - parent_window.create_action( - ("pin" + str(index)), - lambda *_, d=self.app_id, type=self.install_type, index=index: parent_window.pin_flatpak( - self - ), - ) - pin_item = Gio.MenuItem.new(_("Disable Auto Removal"), f"win.pin{index}") - pin_item.set_attribute_value( - "hidden-when", GLib.Variant.new_string("action-disabled") - ) - advanced_menu_model.append_item(pin_item) - - parent_window.create_action( - ("unpin" + str(index)), - lambda *_, d=self.app_id, type=self.install_type, index=index: parent_window.pin_flatpak( - self - ), - ) - unpin_item = Gio.MenuItem.new(_("Enable Auto Removal"), f"win.unpin{index}") - unpin_item.set_attribute_value( - "hidden-when", GLib.Variant.new_string("action-disabled") - ) - advanced_menu_model.append_item(unpin_item) - - if "runtime" not in parent_window.host_flatpaks[index][12]: - parent_window.create_action( - ("snapshot" + str(index)), - lambda *_, row=current_flatpak: SnapshotsWindow( - parent_window, row - ), - ) - snapshot_item = Gio.MenuItem.new( - _("Manage Snapshots"), f"win.snapshot{index}" - ) - advanced_menu_model.append_item(snapshot_item) - - parent_window.create_action( - ("downgrade" + str(index)), - lambda *_, row=current_flatpak, index=index: DowngradeWindow( - parent_window, row, index - ), - ) - downgrade_item = Gio.MenuItem.new(_("Downgrade"), f"win.downgrade{index}") - advanced_menu_model.append_item(downgrade_item) - - if ( - self.app_id in parent_window.system_mask_list - or self.app_id in parent_window.user_mask_list - ): - self.mask_label.set_visible(True) - parent_window.lookup_action(f"mask{index}").set_enabled(False) - else: - parent_window.lookup_action(f"unmask{index}").set_enabled(False) - - if self.is_runtime and self.is_pinned: - parent_window.lookup_action(f"pin{index}").set_enabled(False) - elif self.is_runtime: - parent_window.lookup_action(f"unpin{index}").set_enabled(False) - self.pin_label.set_visible(self.is_pinned) - - row_menu_model.append_section(None, advanced_menu_model) - self.row_menu.set_menu_model(row_menu_model) - - self.info_button_show_or_hide() \ No newline at end of file diff --git a/src/change_version_page/change_version_page.blp b/src/change_version_page/change_version_page.blp new file mode 100644 index 0000000..27f1782 --- /dev/null +++ b/src/change_version_page/change_version_page.blp @@ -0,0 +1,60 @@ +using Gtk 4.0; +using Adw 1; + +template $ChangeVersionPage : Adw.NavigationPage { + title: _("Change Versions"); + Adw.ToolbarView { + [top] + Adw.HeaderBar { + } + Adw.ToastOverlay toast_overlay { + ScrolledWindow scrolled_window {} + } + [bottom] + ActionBar action_bar { + revealed: false; + [center] + Button apply_button { + sensitive: bind action_bar.revealed; + halign: center; + margin-top: 3; + margin-bottom: 3; + Adw.ButtonContent { + label: _("Change Version"); + icon-name: "double-ended-arrows-vertical-symbolic"; + } + styles ["suggested-action", "pill"] + } + } + } +} + +Adw.Clamp versions_clamp { + Box { + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + spacing: 12; + orientation: vertical; + halign: fill; + hexpand: true; + + CheckButton root_group_check_button { + visible: false; + active: true; + } + + Adw.PreferencesGroup mask_group { + Adw.SwitchRow mask_row { + title: _("Disable Updates"); + active: true; + } + } + + Adw.PreferencesGroup versions_group { + title: _("Select a Release"); + description: _("This will uninstall the current release and install the chosen one instead. Note that downgrading can cause issues."); + } + } +} \ No newline at end of file diff --git a/src/change_version_page/change_version_page.py b/src/change_version_page/change_version_page.py new file mode 100644 index 0000000..7ff7f06 --- /dev/null +++ b/src/change_version_page/change_version_page.py @@ -0,0 +1,125 @@ +from gi.repository import Adw, Gtk,GLib, Gio +from .error_toast import ErrorToast +from .host_info import HostInfo +from .loading_status import LoadingStatus +from .change_version_worker import ChangeVersionWorker +import subprocess, os + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/change_version_page/change_version_page.ui") +class ChangeVersionPage(Adw.NavigationPage): + __gtype_name__ = 'ChangeVersionPage' + gtc = Gtk.Template.Child + toast_overlay = gtc() + scrolled_window = gtc() + versions_clamp = gtc() + root_group_check_button = gtc() + mask_group = gtc() + mask_row = gtc() + versions_group = gtc() + action_bar = gtc() + apply_button = gtc() + + selected_commit = None + failure = None + + def get_commits(self, *args): + cmd = ['flatpak-spawn', '--host', 'sh', '-c'] + script = f"LC_ALL=C flatpak remote-info --log {self.package.info['origin']} {self.package.info['ref']} " + installation = self.package.info["installation"] + if installation == "user" or installation == "system": + script += f"--{installation}" + else: + script += f"--installation={installation}" + + cmd.append(script) + + commits = [] + changes = [] + dates = [] + try: + output = subprocess.run(cmd, check=True, capture_output=True, text=True).stdout + lines = output.strip().split('\n') + for line in lines: + line = line.strip().split(": ", 1) + if len(line) < 2: + continue + elif line[0].startswith("Commit"): + commits.append(line[1]) + elif line[0].startswith("Subject"): + changes.append(line[1]) + elif line[0].startswith("Date"): + dates.append(line[1]) + except subprocess.CalledProcessError as cpe: + self.failure = cpe.stderr + return + except Exception as e: + self.failure = str(e) + return + + if not (len(commits) == len(changes) == len(dates)): + self.failure = "Commits, Changes, and Dates are not of equivalent length" + return + + def idle(*args): + for index, commit in enumerate(commits): + row = Adw.ActionRow(title=GLib.markup_escape_text(changes[index]), subtitle=f"{GLib.markup_escape_text(commit)}\n{GLib.markup_escape_text(dates[index])}") + if commit == self.package.cli_info.get("commit", None): + row.set_sensitive(False) + row.add_prefix(Gtk.Image(icon_name="check-plain-symbolic", margin_start=5, margin_end=5)) + row.set_tooltip_text(_("Currently Installed Version")) + else: + check = Gtk.CheckButton() + check.connect("activate", lambda *_, comm=commit: self.set_commit(comm)) + check.set_group(self.root_group_check_button) + row.set_activatable_widget(check) + row.add_prefix(check) + + self.versions_group.add(row) + + GLib.idle_add(idle) + + def set_commit(self, commit): + self.selected_commit = commit + + def get_commits_callback(self, *args): + if not self.failure is None: + self.toast_overlay.add_toast(ErrorToast(_("Could not get versions"), self.failure).toast) + else: + self.scrolled_window.set_child(self.versions_clamp) + + def callback(self, did_error): + HostInfo.main_window.refresh_handler() + if not did_error: + HostInfo.main_window.toast_overlay.add_toast(Adw.Toast(title=_("Changed {}'s Version").format(self.package.info['name']))) + + def error_callback(self, user_facing_label, error_message): + HostInfo.main_window.toast_overlay.add_toast(ErrorToast(user_facing_label, error_message).toast) + + def on_apply(self, *args): + if ChangeVersionWorker.change_version( + self.mask_row.get_active(), + self.package, self.selected_commit, + self.packages_page.changing_version, + self.callback, + self.error_callback, + ): + self.packages_page.set_status(self.packages_page.changing_version) + + def __init__(self, packages_page, package, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.packages_page = packages_page + self.package = package + + # Apply + pkg_name = package.info["name"] + self.set_title(_("{} Versions").format(pkg_name)) + self.mask_row.set_subtitle(_("Ensure that {} will never be updated to a newer version").format(pkg_name)) + self.scrolled_window.set_child(LoadingStatus(_("Fetching Releases"), _("This could take a while"))) + Gio.Task.new(None, None, self.get_commits_callback).run_in_thread(self.get_commits) + + # Connections + self.root_group_check_button.connect("toggled", lambda *_: self.action_bar.set_revealed(True)) + self.apply_button.connect("clicked", self.on_apply) + diff --git a/src/change_version_page/change_version_worker.py b/src/change_version_page/change_version_worker.py new file mode 100644 index 0000000..8aa32e6 --- /dev/null +++ b/src/change_version_page/change_version_worker.py @@ -0,0 +1,114 @@ +from gi.repository import Adw, Gtk, GLib, Gio +from .host_info import HostInfo +import subprocess, re + +class ChangeVersionWorker: + process = None + callback = None + error_callback = None + loading_status = None + did_error = False + + @classmethod + def update_status(this, package_ratio, complete, total): + final_ratio = (package_ratio + complete) / (total or 1) + + print(f"fr: {final_ratio:.2f}") + print("r:", package_ratio, ", c:", complete, ", t:", total) + print("=======================================") + + if not this.loading_status is None: + GLib.idle_add(lambda *_: this.loading_status.progress_bar.set_fraction(final_ratio)) + + @classmethod + def change_version_thread(this, should_mask, package, commit): + try: + cmd = ['flatpak-spawn', '--host', 'pkexec', 'sh', '-c'] + + installation = package.info['installation'] + real_installation = "" + if installation == "user" or installation == "system": + real_installation = f"--{installation}" + else: + real_installation = f"--installation={installation}" + + suffix = "" + unmask_cmd = f"flatpak mask --remove {real_installation} {package.info['id']} && " + change_version_cmd = f"flatpak update {package.info['ref']} --commit={commit} {real_installation} -y" + mask_cmd = f" && flatpak mask {real_installation} {package.info['id']}" + if package.is_masked: + suffix += unmask_cmd + + suffix += change_version_cmd + if should_mask: + suffix += mask_cmd + + cmd.append(suffix) + this.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + percent_pattern = r'\d{1,3}%' + amount_pattern = r'(\d+)/(\d+)' + for line in this.process.stdout: + line = line.strip() + percent_match = re.search(percent_pattern, line) + if percent_match: + ratio = int(percent_match.group()[0:-1]) / 100.0 + amount_match = re.search(amount_pattern, line) + if amount_match: + amount = amount_match.group().split('/') + complete = int(amount[0]) - 1 + total = int(amount[1]) + this.update_status(ratio, complete, total) + else: + this.update_status(ratio, 0, 1) + + this.process.wait(timeout=10) + if error := this.process.communicate()[1].strip(): + this.on_error(_("Error occurred while changing version"), error) + + except subprocess.TimeoutExpired as te: + this.process.terminate() + this.on_error(_("Error occurred while changing version"), _("Failed to exit cleanly")) + + except Exception as e: + this.process.terminate() + this.on_error(_("Error occurred while changing version"), str(e)) + + @classmethod + def cancel(this): + if this.process is None: + return + + try: + this.process.terminate() + this.process.wait(timeout=10) + except Exception as e: + this.on_error(_("Could not cancel version change"), str(e)) + + @classmethod + def on_done(this, *args): + this.process = None + HostInfo.main_window.remove_refresh_lockout("changing version") + if not this.loading_status is None: + this.loading_status.progress_bar.set_fraction(0.0) + + if not this.callback is None: + this.callback(this.did_error) + + @classmethod + def on_error(this, user_facing_label, error_message): + this.did_error = True + if not this.error_callback is None: + this.error_callback(user_facing_label, error_message) + + @classmethod + def change_version(this, should_mask, package, commit, loading_status=None, callback=None, error_callback=None): + if not this.process is None: + this.on_error(_("Could not change version"), _("Another package is changing version.")) + return False + + this.loading_status = loading_status + this.callback = callback + this.error_callback = error_callback + HostInfo.main_window.add_refresh_lockout("changing version") + Gio.Task.new(None, None, this.on_done).run_in_thread(lambda *_: this.change_version_thread(should_mask, package, commit)) + return True diff --git a/src/common.py b/src/common.py deleted file mode 100644 index 0616d5d..0000000 --- a/src/common.py +++ /dev/null @@ -1,535 +0,0 @@ -from gi.repository import GLib, Gtk, Adw, Gio # , Gdk -import os -import subprocess -import pathlib -import time - - -class myUtils: - def __init__(self, window, **kwargs): - self.parent_window = window - self.host_home = str(pathlib.Path.home()) - self.user_data_path = self.host_home + "/.var/app/" - self.install_success = True - self.uninstall_success = True - self.new_env = dict(os.environ) - self.new_env["LC_ALL"] = "C" - self.is_dialog_open = False - - def trash_folder(self, path): - if not os.path.exists(path): - print("error in common.trashFolder: path does not exists. path =", path) - return 1 - try: - subprocess.run( - ["gio", "trash", path], - capture_output=False, - check=True, - env=self.new_env, - ) - return 0 - except subprocess.CalledProcessError as e: - print("error in common.trashFolder: CalledProcessError:", e) - return 2 - - def get_size_with_format(self, path): - return self.get_size_format(self.get_dir_size(path)) - - def get_size_format(self, b): - factor = 1000 - suffix = "B" - for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]: - if b < factor: - return f"{b:.1f}{unit}{suffix}" - b /= factor - return f"{b:.1f}{suffix}" - - def get_dir_size(self, directory): - """Returns the `directory` size in bytes.""" - total = 0 - try: - # print("[+] Getting the size of", directory) - for entry in os.scandir(directory): - if entry.is_symlink(): - continue # Skip symlinks - if entry.is_file(): - # if it's a file, use stat() function - total += entry.stat().st_size - elif entry.is_dir(): - # if it's a directory, recursively call this function - try: - total += self.get_dir_size(entry.path) - except FileNotFoundError: - pass - except NotADirectoryError: - # if `directory` isn't a directory, get the file size then - return os.path.getsize(directory) - except PermissionError: - # if for whatever reason we can't open the folder, return 0 - return 0 - if total == 0: - return 0 - # Adding 4000 seems to make it more accurate to whatever data we can't scan from within the sandbox - return total + 4000 - - def find_app_icon(self, app_id): - icon_theme = Gtk.IconTheme.new() - icon_theme.add_search_path("/var/lib/flatpak/exports/share/icons/") - icon_theme.add_search_path( - self.host_home + "/.local/share/flatpak/exports/share/icons" - ) - - try: - icon_path = ( - icon_theme.lookup_icon( - app_id, None, 512, 1, self.parent_window.get_direction(), 0 - ) - .get_file() - .get_path() - ) - except GLib.GError: - icon_path = None - if icon_path: - image = Gtk.Image.new_from_file(icon_path) - image.set_icon_size(Gtk.IconSize.LARGE) - image.add_css_class("icon-dropshadow") - else: - image = Gtk.Image.new_from_icon_name("application-x-executable-symbolic") - image.set_icon_size(Gtk.IconSize.LARGE) - return image - - def get_host_updates(self): - list = [] - output = subprocess.run( - ["flatpak-spawn", "--host", "flatpak", "update"], - capture_output=True, - text=True, - env=self.new_env, - ).stdout - lines = output.strip().split("\n") - columns = lines[0].split("\t") - data = [columns] - for line in lines[1:]: - row = line.split("\t") - data.append(row) - - for i in range(len(data)): - if data[i][0].find(".") == 2: - list.append(data[i][2]) - - return list - - def get_host_system_pins(self): - output = subprocess.run( - ["flatpak-spawn", "--host", "flatpak", "pin"], - capture_output=True, - text=True, - env=self.new_env, - ).stdout - data = output.strip().split("\n") - for i in range(len(data)): - data[i] = data[i].strip() - return data - - def get_host_user_pins(self): - output = subprocess.run( - ["flatpak-spawn", "--host", "flatpak", "pin", "--user"], - capture_output=True, - text=True, - env=self.new_env, - ).stdout - data = output.strip().split("\n") - for i in range(len(data)): - data[i] = data[i].strip() - return data - - def get_host_remotes(self): - output = subprocess.run( - [ - "flatpak-spawn", - "--host", - "flatpak", - "remotes", - "--columns=all", - "--show-disabled", - ], - capture_output=True, - text=True, - env=self.new_env, - ).stdout - lines = output.strip().split("\n") - columns = lines[0].split("\t") - data = [columns] - for line in lines[1:]: - row = line.split("\t") - data.append(row) - return data - - def get_host_flatpaks(self): - output = subprocess.run( - ["flatpak-spawn", "--host", "flatpak", "list", - "--columns=name,application,version,branch,arch,origin,installation,ref,active,latest,size,options,runtime"], - capture_output=True, - text=True, - env=self.new_env, - ).stdout - lines = output.strip().split("\n") - columns = lines[0].split("\t") - data = [] - for line in lines: - row = line.split("\t") - row.insert(1, " ") - if len(row) < 14: - row.append("") - data.append(row) - - # output = subprocess.run( - # ["flatpak-spawn", "--host", "flatpak", "list", "--columns=runtime"], - # capture_output=True, - # text=True, - # env=self.new_env, - # ).stdout - # lines = output.split("\n") - # for i in range(len(data)): - # data[i].append(lines[i]) - sorted_array = sorted(data, key=lambda item: item[0].lower()) - return sorted_array - - def get_flatpak_info(self, ref, install_type): - output = subprocess.run( - [ - "flatpak-spawn", "--host", "sh", "-c", - f"flatpak info {ref} --{install_type}" - ], - capture_output=True, - text=True - ).stdout - lines = output.strip().split("\n") - columns = lines[0].split("\t") - data = [] - for line in lines: - row = line.split(": ", 1) - for i in range(len(row)): - row[i] = row[i].strip() - data.append(row) - info = {} - maybe_name = data[0][0] - if not "ID" in maybe_name: - info["name"] = data[0][0] - else: - info["name"] = ref.split("/")[0].split(".")[-1] - for i in range(2, len(data)): - if data[i][0] == '': - continue - info[data[i][0]] = data[i][1] - return info - - def get_dependent_runtimes(self): - paks = self.get_host_flatpaks() - dependent_runtimes = [] - for i in range(len(paks)): - current = paks[i] - try: - if current[13] not in dependent_runtimes and current[13] != "": - dependent_runtimes.append(current[13]) - except: - print("Could not get dependent runtime") - return sorted(dependent_runtimes) - - def get_host_masks(self, user_or_system): - output = subprocess.run( - ["flatpak-spawn", "--host", "flatpak", "mask", f"--{user_or_system}"], - capture_output=True, - text=True, - env=self.new_env, - ).stdout - lines = output.strip().split("\n") - for i in range(len(lines)): - lines[i] = lines[i].strip() - return lines - - def mask_flatpak(self, app_id, user_or_system, remove=False): - command = [ - "flatpak-spawn", - "--host", - "flatpak", - "mask", - f"--{user_or_system}", - app_id, - ] - if remove: - command.append("--remove") - response = "" - try: - response = subprocess.run( - command, capture_output=True, text=True, env=self.new_env - ) - except subprocess.CalledProcessError as e: - print(f"Error setting mask for {app_id}:\n", e) - return 1 - if len(response.stderr) > 0: - return 1 - return 0 - - def downgrade_flatpak(self, id, ref, commit, install_type="system", mask=False, mask_list=None): - unmask_cmd = f"flatpak mask --remove --{install_type} {id} && " - update_cmd = f"flatpak update {ref} --commit={commit} --{install_type} -y" - to_run_cmd = "" - if id in mask_list: - to_run_cmd += unmask_cmd - to_run_cmd += update_cmd - if mask: - to_run_cmd += f" && flatpak mask --{install_type} {id}" - command = [ - "flatpak-spawn", - "--host", "pkexec", - "sh", "-c", - to_run_cmd, - ] - if install_type == "user": - command.remove("pkexec") - try: - subprocess.run( - command, capture_output=True, text=True, env=self.new_env, check=True - ) - except subprocess.CalledProcessError as e: - print("Error in common.downgrade_flatpak:", e.stderr) - return 1 - return 0 - - def uninstall_flatpak( - self, ref_arr, type_arr, should_trash, progress_bar=None, status_label=None - ): - self.uninstall_success = True - print(ref_arr) - to_uninstall = [] - for i in range(len(ref_arr)): - to_uninstall.append([ref_arr[i], type_arr[i]]) - - apps = [] - fails = [] - for i in range(len(to_uninstall)): - ref = to_uninstall[i][0] - id = to_uninstall[i][0].split("/")[0] - app_type = to_uninstall[i][1] - apps.append([ref, id, app_type]) - # apps array guide: [app_ref, app_id, user_or_system_install] - - for i in range(len(apps)): - command = [ - "flatpak-spawn", - "--host", - "flatpak", - "remove", - "-y", - f"--{apps[i][2]}", - apps[i][0], - ] - try: - print(apps) - if status_label: - GLib.idle_add( - status_label.set_label, - _("Working on {}\n{} out of {}").format( - apps[i][0], i + 1, len(apps) - ), - ) - subprocess.run( - command, capture_output=False, check=True, env=self.new_env - ) - if progress_bar: - GLib.idle_add(progress_bar.set_visible, True) - GLib.idle_add(progress_bar.set_fraction, (i + 1.0) / len(ref_arr)) - except subprocess.CalledProcessError: - fails.append(apps[i]) - - if len(fails) > 0: # Run this only if there is 1 or more non uninstalled apps - pk_command = [ - "flatpak-spawn", - "--host", - "pkexec", - "flatpak", - "remove", - "-y", - "--system", - ] - print("second uninstall process") - for i in range(len(fails)): - if fails[i][2] == "user": - self.uninstall_success = False - continue # Skip if app is a user install app - - pk_command.append(fails[i][0]) - try: - print(pk_command) - if progress_bar: - GLib.idle_add(progress_bar.set_visible, True) - GLib.idle_add(progress_bar.set_fraction, 0.9) - subprocess.run( - pk_command, capture_output=False, check=True, env=self.new_env - ) - except subprocess.CalledProcessError: - self.uninstall_success = False - - if should_trash: - host_paks = self.get_host_flatpaks() - host_refs = [] - for i in range(len(host_paks)): - host_refs.append(host_paks[i][8]) - - for i in range(len(apps)): - if apps[i][0] in host_refs: - print(f"{apps[i][1]} is still installed") - else: - self.trash_folder(f"{self.user_data_path}{apps[i][1]}") - - if progress_bar: - GLib.idle_add(progress_bar.set_visible, False) - GLib.idle_add(progress_bar.set_fraction, 0.0) - - def install_flatpak( - self, app_arr, remote, user_or_system, progress_bar=None, status_label=None - ): - self.install_success = True - fails = [] - - for i in range(len(app_arr)): - command = ["flatpak-spawn", "--host", "flatpak", "install"] - if remote != None: - command.append(remote) - command.append(f"--{user_or_system}") - command.append("-y") - command.append(app_arr[i]) - try: - if status_label: - GLib.idle_add( - status_label.set_label, - _("Working on {}\n{} out of {}").format( - app_arr[i], i + 1, len(app_arr) - ), - ) - subprocess.run( - command, capture_output=False, check=True, env=self.new_env - ) - if progress_bar: - GLib.idle_add(progress_bar.set_visible, True) - GLib.idle_add(progress_bar.set_fraction, (i + 1.0) / len(app_arr)) - except subprocess.CalledProcessError: - fails.append(app_arr[i]) - - if (len(fails) > 0) and (user_or_system == "system"): - pk_command = [ - "flatpak-spawn", - "--host", - "pkexec", - "flatpak", - "install", - remote, - f"--{user_or_system}", - "-y", - ] - for i in range(len(fails)): - pk_command.append(fails[i]) - try: - if progress_bar: - GLib.idle_add(progress_bar.set_visible, True) - GLib.idle_add(progress_bar.set_fraction, 0.9) - subprocess.run( - pk_command, capture_output=False, check=True, env=self.new_env - ) - except subprocess.CalledProcessError: - self.install_success = False - - if (len(fails) > 0) and (user_or_system == "user"): - self.install_success = False - - if progress_bar: - GLib.idle_add(progress_bar.set_visible, False) - GLib.idle_add(progress_bar.set_fraction, 0.0) - - def run_app(self, ref): - self.run_app_error = False - self.run_app_error_message = "" - try: - subprocess.run( - ["flatpak-spawn", "--host", "flatpak", "run", ref], - check=True, - env=self.new_env, - start_new_session=True, - capture_output=True, - ) - except subprocess.CalledProcessError as e: - self.run_app_error_message = e.stderr.decode() - self.run_app_error = True - - def get_install_type(self, type_arr): - if "disabled" in type_arr: - return "disabled" - if "user" in type_arr: - return "user" - if "system" in type_arr: - return "system" - - def snapshot_apps( - self, - epoch, - app_snapshot_path_arr, - app_version_arr, - app_user_data_arr, - progress_bar=None, - ): - if not ( - len(app_snapshot_path_arr) == len(app_version_arr) == len(app_user_data_arr) - ): - print( - "error in common.snapshotApp: the lengths of app_snapshot_path_arr, app_version_arr, and app_user_data_arr do not match." - ) - return 1 - - fails = [] - - for i in range(len(app_snapshot_path_arr)): - snapshot_path = app_snapshot_path_arr[i] - version = app_version_arr[i] - user_data = app_user_data_arr[i] - command = [ - "tar", - "cafv", - f"{snapshot_path}{epoch}_{version}.tar.zst", - "-C", - f"{user_data}", - ".", - ] - - try: - if not os.path.exists(snapshot_path): - file = Gio.File.new_for_path(snapshot_path) - file.make_directory() - subprocess.run(command, check=True, env=self.new_env) - if progress_bar: - GLib.idle_add(progress_bar.set_visible, True) - GLib.idle_add( - progress_bar.set_fraction, - (i + 1.0) / len(app_snapshot_path_arr), - ) - except subprocess.CalledProcessError as e: - print("error in common.snapshotApp:", e) - fails.append(user_data) - - if ( - int(time.time()) == epoch - ): # Wait 1s if the snapshot is made too quickly, to prevent overriding a snapshot file - subprocess.run(["sleep", "1s"]) - - if progress_bar: - GLib.idle_add(progress_bar.set_visible, False) - GLib.idle_add(progress_bar.set_fraction, 0.0) - - if len(fails) > 0: - print("These paths could not be archived:") - for i in range(fails): - print(fails[i]) - print("") - return 1 - else: - return 0 diff --git a/src/downgrade_window.py b/src/downgrade_window.py deleted file mode 100644 index b085cd0..0000000 --- a/src/downgrade_window.py +++ /dev/null @@ -1,168 +0,0 @@ -from gi.repository import Gtk, Adw, GLib, Gdk, Gio -from .common import myUtils -import subprocess -import os -import pathlib - - -@Gtk.Template(resource_path="/io/github/flattool/Warehouse/../data/ui/downgrade.ui") -class DowngradeWindow(Adw.Dialog): - __gtype_name__ = "DowngradeWindow" - - new_env = dict(os.environ) - new_env["LC_ALL"] = "C" - - apply_button = Gtk.Template.Child() - versions_group = Gtk.Template.Child() - toast_overlay = Gtk.Template.Child() - mask_row = Gtk.Template.Child() - main_toolbar_view = Gtk.Template.Child() - loading = Gtk.Template.Child() - loading_label = Gtk.Template.Child() - main_stack = Gtk.Template.Child() - outerbox = Gtk.Template.Child() - action_bar = Gtk.Template.Child() - - def selection_handler(self, button, index): - self.action_bar.set_revealed(True) - if button.get_active(): - self.commit_to_use = self.versions[index][0] - - def get_commits(self): - output = subprocess.run( - [ - "flatpak-spawn", "--host", "sh", "-c", - f"LC_ALL=C flatpak remote-info --log {self.remote} {self.app_ref} --{self.install_type}" - ], - capture_output=True, - text=True - ).stdout - lines = output.strip().split("\n") - columns = lines[0].split("\t") - data = [columns] - for line in lines[1:]: - row = line.split("\t") - data.append(row[0].strip()) - - commits = [] - changes = [] - dates = [] - for i in range(len(data)): - line = data[i] - - if "Commit:" in line: - commits.append(line.replace("Commit: ", "")) - - if "Subject:" in line: - changes.append(line.replace("Subject: ", "")) - - if "Date:" in line: - dates.append(line.replace("Date: ", "")) - - for i in range(len(commits)): - self.versions.append([commits[i], changes[i], dates[i]]) - - def commits_callback(self): - group_button = Gtk.CheckButton(visible=False) - self.versions_group.add(group_button) - for i in range(len(self.versions)): - version = self.versions[i] - date_time = version[2].split(" ") - date = date_time[0].split("-") - offset = date_time[2][:3] + ":" + date_time[2][3:] - time = date_time[1].split(":") - display_time = GLib.DateTime.new( - GLib.TimeZone.new(offset), - int(date[0]), - int(date[1]), - int(date[2]), - int(time[0]), - int(time[1]), - int(time[2]), - ) - display_time = display_time.format("%x %X") - change = version[1].split("(") - row = Adw.ActionRow( - title=GLib.markup_escape_text(change[0]), subtitle=str(display_time) - ) - row.set_tooltip_text(_("Commit Hash: {}").format(version[0])) - select = Gtk.CheckButton() - select.connect("toggled", self.selection_handler, i) - select.set_group(group_button) - - version.append(select) - row.set_activatable_widget(select) - row.add_prefix(select) - self.versions_group.add(row) - self.main_stack.set_visible_child(self.outerbox) - self.apply_button.set_visible(True) - self.mask_row.grab_focus() # Don't know why, but I need this in order for escape to close the window - - def generate_list(self): - task = Gio.Task.new(None, None, lambda *_: self.commits_callback()) - task.run_in_thread(lambda *_: self.get_commits()) - - def downgrade_callack(self): - self.set_can_close(True) - - if self.response != 0: - self.parent_window.toast_overlay.add_toast( - Adw.Toast.new(_("Could not downgrade {}").format(self.app_name)) - ) - self.apply_button.set_sensitive(True) - - self.parent_window.refresh_list_of_flatpaks(self) - self.close() - - def downgrade_thread(self): - mask_list = None - if self.install_type == "system": - mask_list = self.parent_window.system_mask_list - if self.install_type == "user": - mask_list = self.parent_window.user_mask_list - self.response = self.my_utils.downgrade_flatpak( - self.app_id, self.app_ref, self.commit_to_use, self.install_type, self.mask_row.get_active(), mask_list - ) - - def on_apply(self): - self.loading_label.set_label(_("Downgradingâ€Ļ")) - self.set_can_close(False) - self.main_stack.set_visible_child(self.loading) - self.apply_button.set_visible(False) - - task = Gio.Task.new(None, None, lambda *_: self.downgrade_callack()) - task.run_in_thread(lambda *_: self.downgrade_thread()) - - def __init__(self, parent_window, flatpak_row, index, **kwargs): - super().__init__(**kwargs) - - # Create Variables - self.my_utils = myUtils(self) - self.app_name = flatpak_row[0] - self.app_id = flatpak_row[2] - self.remote = flatpak_row[6] - self.install_type = flatpak_row[7] - self.app_ref = flatpak_row[8] - self.versions = [] - self.commit_to_use = "" - self.parent_window = parent_window - self.flatpak_row = flatpak_row - self.response = 0 - self.window_title = _("Downgrade {}").format(self.app_name) - self.index = index - - # Connections - self.apply_button.connect("clicked", lambda *_: self.on_apply()) - - # Apply - self.mask_row.set_subtitle( - _("Ensure that {} will never be updated to a newer version").format( - self.app_name - ) - ) - - self.set_title(self.window_title) - - self.generate_list() - - self.present(parent_window) diff --git a/src/filter_window.py b/src/filter_window.py deleted file mode 100644 index 1af34eb..0000000 --- a/src/filter_window.py +++ /dev/null @@ -1,226 +0,0 @@ -from gi.repository import Gtk, Adw, GLib, Gdk, Gio -from .common import myUtils -import subprocess -import os -import pathlib - - -@Gtk.Template(resource_path="/io/github/flattool/Warehouse/../data/ui/filter.ui") -class FilterWindow(Adw.Dialog): - __gtype_name__ = "FilterWindow" - - show_apps_switch = Gtk.Template.Child() - show_runtimes_switch = Gtk.Template.Child() - remotes_expander = Gtk.Template.Child() - runtimes_expander = Gtk.Template.Child() - reset_button = Gtk.Template.Child() - - is_open = False - - def gsettings_bool_set(self, key, value): - self.settings.set_boolean(key, value) - self.check_is_resetable() - self.main_window.apply_filter() - - def check_is_resetable(self): - if not self.show_apps_switch.get_active(): - self.reset_button.set_sensitive(True) - return - if self.show_runtimes_switch.get_active(): - self.reset_button.set_sensitive(True) - return - if self.total_remotes_selected != 0: - self.reset_button.set_sensitive(True) - return - if self.total_runtimes_selected != 0: - self.reset_button.set_sensitive(True) - return - self.reset_button.set_sensitive(False) - - def row_subtitle_updater(self): - if self.total_runtimes_selected > 0: - self.runtimes_expander.set_subtitle( - _("{} selected").format(self.total_runtimes_selected) - ) - else: - self.runtimes_expander.set_subtitle("") - if self.total_remotes_selected > 0: - self.remotes_expander.set_subtitle( - _("{} selected").format(self.total_remotes_selected) - ) - else: - self.remotes_expander.set_subtitle("") - - def reset_filter_gsettings(self): - self.show_apps_switch.set_active(True) - self.show_runtimes_switch.set_active(False) - for button in self.remote_checkboxes: - button.set_active(False) - for button in self.runtime_checkboxes: - button.set_active(False) - for key in self.settings.list_keys(): - self.settings.reset(key) - self.total_remotes_selected = 0 - self.total_runtimes_selected = 0 - self.row_subtitle_updater() - self.reset_button.set_sensitive(False) - - def runtime_handler(self, button, runtime): - if button.get_active(): - self.total_runtimes_selected += 1 - self.runtimes_string = self.runtimes_string.replace("all", "") - self.runtimes_string += f"{runtime}," - else: - self.total_runtimes_selected -= 1 - self.runtimes_string = self.runtimes_string.replace(f"{runtime},", "") - if len(self.runtimes_string) < 1: - self.runtimes_string += "all" - self.settings.set_string("runtimes-list", self.runtimes_string) - self.check_is_resetable() - self.row_subtitle_updater() - self.main_window.apply_filter() - - def remote_handler(self, button, remote, install_type): - if button.get_active(): - self.total_remotes_selected += 1 - self.remotes_string = self.remotes_string.replace("all", "") - self.remotes_string += f"{remote}<>{install_type};" - else: - self.total_remotes_selected -= 1 - self.remotes_string = self.remotes_string.replace( - f"{remote}<>{install_type};", "" - ) - if len(self.remotes_string) < 1: - self.remotes_string += "all" - self.settings.set_string("remotes-list", self.remotes_string) - self.check_is_resetable() - self.row_subtitle_updater() - self.main_window.apply_filter() - - def generate_remotes(self): - if ( - len(self.host_remotes) < 2 - ): # Don't give the ability to filter by remotes if there is only 1 - self.remotes_expander.set_visible(False) - - total = 0 - for i in range(len(self.host_remotes)): - try: - name = self.host_remotes[i][0] - title = self.host_remotes[i][1] - url = self.host_remotes[i][2] - install_type = self.my_utils.get_install_type(self.host_remotes[i][7]) - remote_row = Adw.ActionRow(title=title) - if "disabled" in install_type: - continue - total += 1 - if title == "-": - remote_row.set_title(name) - self.remotes_expander.add_row(remote_row) - label = Gtk.Label(label=("{} wide").format(install_type)) - label.add_css_class("subtitle") - remote_check = Gtk.CheckButton() - if name in self.remotes_string: - remote_check.set_active(True) - self.total_remotes_selected += 1 - remote_check.connect( - "toggled", - lambda button=remote_check, remote=name, install_type=install_type: self.remote_handler( - button, remote, install_type - ), - ) - self.remote_checkboxes.append(remote_check) - - if "user" in install_type: - remote_row.set_subtitle(_("User wide")) - elif "system" in install_type: - remote_row.set_subtitle(_("System wide")) - else: - remote_row.set_subtitle(_("Unknown install type")) - - remote_row.add_suffix(remote_check) - remote_row.set_activatable_widget(remote_check) - except Exception as e: - print( - "error at filter_window.generate_remotes: Could not make remote row. error", - e, - ) - - self.row_subtitle_updater() - if total < 2: - self.remotes_expander.set_visible(False) - - def generate_runtimes(self): - if ( - len(self.dependent_runtimes) < 2 - ): # Don't give the ability to filter by runtimes if there is only 1 - self.runtimes_expander.set_visible(False) - - for current in self.dependent_runtimes: - runtime_row = Adw.ActionRow(title=current) - runtime_check = Gtk.CheckButton() - if current in self.runtimes_string: - runtime_check.set_active(True) - self.total_runtimes_selected += 1 - runtime_check.connect( - "toggled", - lambda button=runtime_check, runtime=current: self.runtime_handler( - button, runtime - ), - ) - self.runtime_checkboxes.append(runtime_check) - runtime_row.add_suffix(runtime_check) - runtime_row.set_activatable_widget(runtime_check) - self.runtimes_expander.add_row(runtime_row) - self.row_subtitle_updater() - - def __init__(self, main_window, **kwargs): - super().__init__(**kwargs) - - # Create Variables - self.main_window = main_window - self.my_utils = myUtils(self) - self.host_remotes = self.my_utils.get_host_remotes() - self.dependent_runtimes = self.my_utils.get_dependent_runtimes() - self.settings = Gio.Settings.new("io.github.flattool.Warehouse.filter") - self.remotes_string = self.settings.get_string("remotes-list") - self.runtimes_string = self.settings.get_string("runtimes-list") - self.remote_checkboxes = [] - self.runtime_checkboxes = [] - self.total_remotes_selected = 0 - self.total_runtimes_selected = 0 - - self.show_apps_switch.set_active(self.settings.get_boolean("show-apps")) - self.show_runtimes_switch.set_active(self.settings.get_boolean("show-runtimes")) - - # Connections - self.show_apps_switch.connect( - "state-set", - lambda button, state: self.gsettings_bool_set("show-apps", state), - ) - self.show_runtimes_switch.connect( - "state-set", - lambda button, state: self.gsettings_bool_set("show-runtimes", state), - ) - self.reset_button.connect("clicked", lambda *_: self.reset_filter_gsettings()) - - # Calls - if self.host_remotes[0][0] == "": - self.remotes_expander.set_visible(False) - else: - self.generate_remotes() - - if self.dependent_runtimes == []: - self.runtimes_expander.set_visible(False) - else: - self.generate_runtimes() - self.check_is_resetable() - - def set_is_open_false(*args): - self.__class__.is_open = False - self.connect("closed", set_is_open_false) - if self.__class__.is_open: - return - else: - self.present(main_window) - self.__class__.is_open = True diff --git a/src/gtk/app_row.blp b/src/gtk/app_row.blp new file mode 100644 index 0000000..040fc1b --- /dev/null +++ b/src/gtk/app_row.blp @@ -0,0 +1,43 @@ +using Gtk 4.0; +using Adw 1; + +template $AppRow : Adw.ActionRow { + activatable: true; + [prefix] + Image image { + icon-size: large; + icon-name: "application-x-executable-symbolic"; + } + [suffix] + Image eol_package_package_status_icon { + icon-name: "error-symbolic"; + tooltip-text: _("This package is End Of Life, and will not receive any security updates"); + visible: false; + styles["error"] + } + [suffix] + Image eol_runtime_status_icon { + icon-name: "error-symbolic"; + tooltip-text: _("This app's runtime is End Of Life, and will not receive any security updates"); + visible: false; + styles["error"] + } + [suffix] + Image pinned_status_icon { + icon-name: "pin-symbolic"; + tooltip-text: _("This runtime will never be automatically removed"); + visible: false; + } + [suffix] + Image masked_status_icon { + icon-name: "software-update-urgent-symbolic"; + tooltip-text: _("Updates are disabled for this package"); + visible: false; + } + [suffix] + CheckButton check_button { + margin-start: 6; + styles["selection-mode"] + visible: false; + } +} \ No newline at end of file diff --git a/src/gtk/app_row.py b/src/gtk/app_row.py new file mode 100644 index 0000000..44c4343 --- /dev/null +++ b/src/gtk/app_row.py @@ -0,0 +1,45 @@ +from gi.repository import Adw, Gtk, GLib +from .host_info import HostInfo + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/gtk/app_row.ui") +class AppRow(Adw.ActionRow): + __gtype_name__ = 'AppRow' + gtc = Gtk.Template.Child + image = gtc() + eol_package_package_status_icon = gtc() + eol_runtime_status_icon = gtc() + pinned_status_icon = gtc() + masked_status_icon = gtc() + check_button = gtc() + + def idle_stuff(self): + if self.package.icon_path: + self.image.add_css_class("icon-dropshadow") + self.image.set_from_file(self.package.icon_path) + + def gesture_handler(self, *args): + if self.on_long_press: + self.on_long_press(self) + + def __init__(self, package, on_long_press=None, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.package = package + self.on_long_press = on_long_press + self.rclick_gesture = Gtk.GestureClick(button=3) + self.long_press_gesture = Gtk.GestureLongPress() + + # Apply + GLib.idle_add(lambda *_: self.set_title(package.info["name"])) + GLib.idle_add(lambda *_: self.set_subtitle(package.info["id"])) + GLib.idle_add(lambda *_: self.idle_stuff()) + self.add_controller(self.rclick_gesture) + self.add_controller(self.long_press_gesture) + if package.info['id'] == "io.github.flattool.Warehouse": + self.check_button.set_active = lambda *_: None + self.check_button.set_sensitive(False) + + # Connections + self.rclick_gesture.connect("released", self.gesture_handler) + self.long_press_gesture.connect("pressed", self.gesture_handler) diff --git a/src/gtk/attempt_install_dialog.blp b/src/gtk/attempt_install_dialog.blp new file mode 100644 index 0000000..27baf36 --- /dev/null +++ b/src/gtk/attempt_install_dialog.blp @@ -0,0 +1,15 @@ +using Gtk 4.0; +using Adw 1; + +template $AttemptInstallDialog : Adw.AlertDialog { + heading: _("Attempt an Install?"); + body: _("Warehouse will try to install the matching packages."); + responses [ + cancel: _("Cancel"), + continue: _("Install") suggested, + ] + Adw.PreferencesGroup preferences_group { + title: _("Choose a Remote"); + description: _("Select a remote to attempt to install from"); + } +} diff --git a/src/gtk/attempt_install_dialog.py b/src/gtk/attempt_install_dialog.py new file mode 100644 index 0000000..f367634 --- /dev/null +++ b/src/gtk/attempt_install_dialog.py @@ -0,0 +1,76 @@ +from gi.repository import Adw, Gtk +from .host_info import HostInfo +from .error_toast import ErrorToast + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/gtk/attempt_install_dialog.ui") +class AttemptInstallDialog(Adw.AlertDialog): + __gtype_name__ = "AttemptInstallDialog" + gtc = Gtk.Template.Child + + preferences_group = gtc() + + def generate_list(self): + for installation, remotes in HostInfo.remotes.items(): + for remote in remotes: + if remote.disabled: + continue + + row = Adw.ActionRow(title=remote.title, subtitle=_("Installation: {}").format(installation)) + row.remote_name = remote.name + row.remote_installation = installation + button = Gtk.CheckButton() + row.add_prefix(button) + row.check_button = button + row.set_activatable_widget(button) + self.rows.append(row) + self.preferences_group.add(row) + if len(self.rows) > 1: + button.set_group(self.rows[0].check_button) + else: + button.activate() + + def on_response(self, dialog, response): + if response != "continue": + if not self.callback is None: + self.callback(False) + + return + + active_row = None + for row in self.rows: + if row.check_button.get_active(): + active_row = row + break + + if not active_row is None: + install_page = HostInfo.main_window.pages[HostInfo.main_window.install_row] + HostInfo.main_window.activate_row(HostInfo.main_window.install_row) + self.callback(True) + install_page.install_packages([{ + "remote": row.remote_name, + "installation": row.remote_installation, + "package_names": self.package_names, + "extra_flags": [], + }]) + elif not self.callback is None: + self.callback(False) + + def __init__(self, package_names, callback=None, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.rows = [] + self.package_names = package_names + self.callback = callback + + # Apply + self.generate_list() + if len(self.rows) == 1: + self.set_extra_child(None) + elif len(self.rows) < 1: + HostInfo.main_window.toast_overlay.add_toast(ErrorToast(_("Can't find matching packages"), _("Your system has no remotes added")).toast) + + self.present(HostInfo.main_window) + + # Connections + self.connect("response", self.on_response) diff --git a/src/gtk/error_toast.py b/src/gtk/error_toast.py new file mode 100644 index 0000000..75e6251 --- /dev/null +++ b/src/gtk/error_toast.py @@ -0,0 +1,29 @@ +from gi.repository import Adw, Gtk, Gdk, GLib + +class ErrorToast: + main_window = None + def __init__(self, display_msg, error_msg): + + def on_response(dialog, response_id): + if response_id == "copy": + self.clipboard.set(error_msg) + + # Extra Object Creation + self.toast = Adw.Toast(title=display_msg, button_label=_("Details")) + popup = Adw.AlertDialog.new(display_msg) + self.clipboard = Gdk.Display.get_default().get_clipboard() + + # Apply + print(display_msg) + print(error_msg) + popup.add_response("copy", _("Copy")) + popup.add_response("ok", _("OK")) + lb = Gtk.Label(selectable=True, wrap=True)#, natural_wrap_mode=Gtk.NaturalWrapMode.WORD) + lb.set_markup(f"{GLib.markup_escape_text(error_msg)}") + # lb.set_label(error_msg) + # lb.set_selectable(True) + popup.set_extra_child(lb) + + # Connections + self.toast.connect("button-clicked", lambda *_: popup.present(self.main_window)) + popup.connect("response", on_response) diff --git a/src/gtk/help-overlay.blp b/src/gtk/help-overlay.blp index dac10e0..13e5206 100644 --- a/src/gtk/help-overlay.blp +++ b/src/gtk/help-overlay.blp @@ -1,70 +1,123 @@ using Gtk 4.0; ShortcutsWindow help_overlay { - modal: true; - - ShortcutsSection { - section-name: "shortcuts"; - // max-height: 8; - - ShortcutsGroup { - title: _("App Management"); - - ShortcutsShortcut { - title: _("Search"); - action-name: "app.search"; - } - - ShortcutsShortcut { - title: _("Set Filters"); - action-name: "app.set-filter"; - } - - ShortcutsShortcut { - title: _("Refresh"); - action-name: "app.refresh-list"; - } - - ShortcutsShortcut { - title: _("Toggle Selection Mode"); - action-name: "app.toggle-batch-mode"; - } - } - ShortcutsGroup { - title: _("More Functions"); - - ShortcutsShortcut { - title: _("Manage Leftover Data"); - action-name: "app.manage-data-folders"; - } - - ShortcutsShortcut { - title: _("Manage Remotes"); - action-name: "app.show-remotes-window"; - } - - ShortcutsShortcut { - title: _("Install From File"); - action-name: "app.install-from-file"; - } - } - ShortcutsGroup { - title: _("General"); - - ShortcutsShortcut { - title: _("Open Menu"); - action-name: "app.open-menu"; - } - - ShortcutsShortcut { - title: _("Show Shortcuts"); - action-name: "win.show-help-overlay"; - } - - ShortcutsShortcut { - title: _("Quit"); - action-name: "app.quit"; - } - } - } + modal: true; + ShortcutsSection { + section-name: "shortcuts"; + max-height: 17; + ShortcutsGroup { + title: _("General"); + ShortcutsShortcut { + title: _("Refresh"); + action-name: "app.refresh"; + } + ShortcutsShortcut { + title: _("Open Files"); + action-name: "app.open-files"; + } + ShortcutsShortcut { + title: _("Open Menu"); + action-name: "app.open-menu"; + } + ShortcutsShortcut { + title: _("Show Shortcuts"); + action-name: "win.show-help-overlay"; + } + ShortcutsShortcut { + title: _("Quit"); + action-name: "app.quit"; + } + } + ShortcutsGroup { + title: _("Navigation"); + ShortcutsShortcut { + title: _("Show Packages Page"); + action-name: "app.show-packages-page"; + } + ShortcutsShortcut { + title: _("Show Remotes Page"); + action-name: "app.show-remotes-page"; + } + ShortcutsShortcut { + title: _("Show User Data Page"); + action-name: "app.show-user-data-page"; + } + ShortcutsShortcut { + title: _("Show Snapshots Page"); + action-name: "app.show-snapshots-page"; + } + ShortcutsShortcut { + title: _("Show Install Page"); + action-name: "app.show-install-page"; + } + } + ShortcutsGroup { + title: _("Packages Page"); + ShortcutsShortcut { + title: _("Search Mode"); + action-name: "app.search-mode"; + } + ShortcutsShortcut { + title: _("Edit Filters"); + action-name: "app.filter"; + } + ShortcutsShortcut { + title: _("Selection Mode"); + action-name: "app.toggle-select-mode"; + } + } + ShortcutsGroup { + title: _("Remotes Page"); + ShortcutsShortcut { + title: _("Search Mode"); + action-name: "app.search-mode"; + } + ShortcutsShortcut { + title: _("Show or Hide Disabled Remotes"); + action-name: "app.filter"; + } + ShortcutsShortcut { + title: _("New Remote"); + action-name: "app.new"; + } + } + ShortcutsGroup { + title: _("User Data Page"); + ShortcutsShortcut { + title: _("Search Mode"); + action-name: "app.search-mode"; + } + ShortcutsShortcut { + title: _("Edit Sorting Modes"); + action-name: "app.filter"; + } + ShortcutsShortcut { + title: _("Selection Mode"); + action-name: "app.toggle-select-mode"; + } + ShortcutsShortcut { + title: _("Show Active Data"); + action-name: "app.active-data-view"; + } + ShortcutsShortcut { + title: _("Show Leftover Data"); + action-name: "app.leftover-data-view"; + } + } + ShortcutsGroup { + title: _("Snapshots Page"); + ShortcutsShortcut { + title: _("Search Mode"); + action-name: "app.search-mode"; + } + ShortcutsShortcut { + title: _("Selection Mode"); + action-name: "app.toggle-select-mode"; + } + ShortcutsShortcut { + title: _("New Snapshots"); + action-name: "app.new"; + } + } + } } diff --git a/src/gtk/installation_chooser.blp b/src/gtk/installation_chooser.blp new file mode 100644 index 0000000..16de977 --- /dev/null +++ b/src/gtk/installation_chooser.blp @@ -0,0 +1,40 @@ +using Gtk 4.0; +using Adw 1; + +template $InstallationChooser : Adw.PreferencesGroup { + title: _("Choose Installation"); + Adw.ActionRow user_row { + [prefix] + CheckButton user_check { + active: true; + } + title: _("User"); + activatable-widget: user_check; + } + Adw.ActionRow system_row { + [prefix] + CheckButton system_check { + group: user_check; + } + title: _("System"); + activatable-widget: system_check; + } + Adw.ActionRow single_row { + visible: false; + [prefix] + CheckButton single_check { + group: user_check; + } + subtitle: _("Custom installation"); + activatable-widget: single_check; + } + Adw.ComboRow choice_row { + visible: false; + [prefix] + CheckButton choice_check { + group: user_check; + } + title: _("Other Installation"); + subtitle: _("Choose a custom installation"); + } +} \ No newline at end of file diff --git a/src/gtk/installation_chooser.py b/src/gtk/installation_chooser.py new file mode 100644 index 0000000..532b9ca --- /dev/null +++ b/src/gtk/installation_chooser.py @@ -0,0 +1,61 @@ +from gi.repository import Adw, Gtk +from .host_info import HostInfo + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/gtk/installation_chooser.ui") +class InstallationChooser(Adw.PreferencesGroup): + __gtype_name__ = 'InstallationChooser' + gtc = Gtk.Template.Child + + user_row = gtc() + system_row = gtc() + single_row = gtc() + choice_row = gtc() + user_check = gtc() + system_check = gtc() + single_check = gtc() + choice_check = gtc() + + def get_installation(self): + for button, func in self.check_buttons.items(): + if button.get_active(): + return func() + + return "" # Case for when no button is active (which shouldn't happen) + + def set_content_strings(self, content_name, is_plural): + if is_plural: + self.user_row.set_subtitle(_("These {} will only be available to you").format(content_name)) + self.system_row.set_subtitle(_("These {} will be available to everyone").format(content_name)) + self.set_description(_("Choose how these {} will be installed").format(content_name)) + else: + self.user_row.set_subtitle(_("This {} will only be available to you").format(content_name)) + self.system_row.set_subtitle(_("This {} will be available to everyone").format(content_name)) + self.set_description(_("Choose how this {} will be installed").format(content_name)) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.check_buttons = { + self.user_check : lambda: "user", + self.system_check: lambda: "system", + self.single_check: self.single_row.get_title, + self.choice_check: lambda: self.choice_row.get_selected_item().get_string(), + } + + # Apply + custom_installations = [] + for installation in HostInfo.installations: + if installation.startswith("user") or installation.startswith("system"): + continue + + custom_installations.append(installation) + + if len(custom_installations) == 1: + self.single_row.set_visible(True) + self.single_row.set_title(custom_installations[0]) + elif len(custom_installations) > 1: + self.choice_row.set_visible(True) + self.choice_row.set_model(Gtk.StringList(strings=custom_installations)) + + # Connections + self.choice_row.connect("notify::css-classes", lambda *_: self.choice_check.activate()) diff --git a/src/gtk/loading_status.blp b/src/gtk/loading_status.blp new file mode 100644 index 0000000..a9c397a --- /dev/null +++ b/src/gtk/loading_status.blp @@ -0,0 +1,57 @@ +using Gtk 4.0; +using Adw 1; + +template $LoadingStatus : ScrolledWindow { + Box { + orientation: vertical; + valign: center; + halign: fill; + spacing: 12; + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + Adw.Spinner { + height-request: 30; + margin-bottom: 12; + opacity: 0.5; + } + Label title_label { + label: "No Title Set"; + wrap: true; + justify: center; + styles ["title-1"] + } + Label description_label { + label: "No Description Set"; + wrap: true; + justify: center; + styles ["description", "body"] + } + Adw.Clamp progress_clamp { + margin-start: 24; + margin-end: 24; + margin-top: 12; + margin-bottom: 12; + maximum-size: 400; + Box { + halign: fill; + hexpand: true; + spacing: 12; + ProgressBar progress_bar { + halign: fill; + hexpand: true; + valign: center; + } + Label progress_label { + valign: center; + } + } + } + Button button { + label: _("Cancel"); + styles ["pill"] + halign: center; + } + } +} diff --git a/src/gtk/loading_status.py b/src/gtk/loading_status.py new file mode 100644 index 0000000..0cbd978 --- /dev/null +++ b/src/gtk/loading_status.py @@ -0,0 +1,25 @@ +from gi.repository import Gtk, GLib +from .host_info import HostInfo + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/gtk/loading_status.ui") +class LoadingStatus(Gtk.ScrolledWindow): + __gtype_name__ = 'LoadingStatus' + gtc = Gtk.Template.Child + + title_label = gtc() + description_label = gtc() + progress_clamp = gtc() + progress_bar = gtc() + progress_label = gtc() + button = gtc() + + def __init__(self, title, description, show_progress=False, on_cancel=None, **kwargs): + super().__init__(**kwargs) + + self.title_label.set_label(GLib.markup_escape_text(title)) + self.description_label.set_label(GLib.markup_escape_text(description)) + self.progress_clamp.set_visible(show_progress) + if on_cancel is None: + self.button.set_visible(False) + else: + self.button.connect("clicked", lambda *_: on_cancel()) diff --git a/src/gtk/sidebar_button.py b/src/gtk/sidebar_button.py new file mode 100644 index 0000000..7e12753 --- /dev/null +++ b/src/gtk/sidebar_button.py @@ -0,0 +1,19 @@ +from gi.repository import Gtk +from .host_info import HostInfo + +class SidebarButton(Gtk.Button): + __gtype_name__ = "SidebarButton" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + main_split = HostInfo.main_window.main_split + + # Connections + main_split.connect("notify::collapsed", lambda *_: self.set_visible(main_split.get_collapsed())) + self.connect("clicked", lambda *_: main_split.set_show_sidebar(True)) + + # Apply + self.set_icon_name("dock-left-symbolic") + self.set_tooltip_text(_("Show Sidebar")) diff --git a/src/host_info.py b/src/host_info.py new file mode 100644 index 0000000..8b352cd --- /dev/null +++ b/src/host_info.py @@ -0,0 +1,384 @@ +from gi.repository import Gio, Gtk, GLib, Gdk +from .error_toast import ErrorToast +import subprocess, os, pathlib + +home = f"{pathlib.Path.home()}" +icon_theme = Gtk.IconTheme.new() +icon_theme.add_search_path(f"{home}/.local/share/flatpak/exports/share/icons") +direction = Gtk.Image().get_direction() + +class Flatpak: + + def open_app(self, callback=None): + self.failed_app_run = None + def thread(*args): + if self.is_runtime: + self.failed_app_run = "error: cannot open a runtime" + try: + subprocess.run(['flatpak-spawn', '--host', 'flatpak', 'run', f"{self.info['ref']}"], capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as cpe: + self.failed_app_run = cpe + except Exception as e: + self.failed_app_run = e + + Gio.Task.new(None, None, callback).run_in_thread(thread) + + def open_data(self): + if not os.path.exists(self.data_path): + return f"Path '{self.data_path}' does not exist" + try: + Gio.AppInfo.launch_default_for_uri(f"file://{self.data_path}", None) + except GLib.GError as e: + return e + + def get_data_size(self, callback=None): + size = [None] + def thread(*args): + sed = "sed 's/K/ KB/; s/M/ MB/; s/G/ GB/; s/T/ TB/; s/P/ PB/;'" + size[0] = subprocess.run(['sh', '-c', f"du -sh {self.data_path} | {sed}"], capture_output=True, text=True).stdout.split("\t")[0] + def on_done(*arg): + if callback: + callback(f"~ {size[0]}") + Gio.Task.new(None, None, on_done).run_in_thread(thread) + + def trash_data(self, callback=None): + try: + subprocess.run(['gio', 'trash', self.data_path], capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as cpe: + raise cpe + except Exception as e: + raise e + + def set_mask(self, should_mask, callback=None): + self.failed_mask = None + def thread(*args): + cmd = ['flatpak-spawn', '--host', 'flatpak', 'mask', self.info["id"]] + installation = self.info["installation"] + if installation == "user" or installation == "system": + cmd.append(f"--{installation}") + else: + cmd.append(f"--installation={installation}") + + if not should_mask: + cmd.append("--remove") + + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + self.is_masked = should_mask + except subprocess.CalledProcessError as cpe: + self.failed_mask = cpe + except Exception as e: + self.failed_mask = e + + Gio.Task.new(None, None, callback).run_in_thread(thread) + + def set_pin(self, should_pin, callback=None): + self.failed_pin = None + if not self.is_runtime: + self.failed_pin = "Cannot pin an application" + + def thread(*args): + cmd = ['flatpak-spawn', '--host', 'flatpak', 'pin', f"runtime/{self.info['ref']}"] + installation = self.info["installation"] + if installation == "user" or installation == "system": + cmd.append(f"--{installation}") + else: + cmd.append(f"--installation={installation}") + + if not should_pin: + cmd.append("--remove") + + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as cpe: + self.failed_pin = cpe + except Exception as e: + self.failed_mask = e + + Gio.Task.new(None, None, callback).run_in_thread(thread) + + def uninstall(self, callee_callback=None): + self.failed_uninstall = None + + def callback(*args): + HostInfo.main_window.remove_refresh_lockout("uninstalling packages") + if not callee_callback is None: + callee_callback() + + def thread(*args): + HostInfo.main_window.add_refresh_lockout("uninstalling packages") + cmd = ['flatpak-spawn', '--host', 'flatpak', 'uninstall', '-y', self.info["ref"]] + installation = self.info["installation"] + if installation == "system" or installation == "user": + cmd.append(f"--{installation}") + else: + cmd.append(f"--installation={installation}") + + try: + subprocess.run(cmd, check=True, text=True, capture_output=True) + except subprocess.CalledProcessError as cpe: + self.failed_uninstall = cpe + except Exception as e: + self.failed_uninstall = e + + Gio.Task.new(None, None, callback).run_in_thread(thread) + + def get_cli_info(self): + cli_info = {} + cmd = "LC_ALL=C flatpak info " + installation = self.info["installation"] + + if installation == "user": + cmd += "--user " + elif installation == "system": + cmd += "--system " + else: + cmd += f"--installation={installation} " + + cmd += self.info["ref"] + try: + output = subprocess.run( + ['flatpak-spawn', '--host', 'sh', '-c', cmd], + text=True, capture_output=True + ).stdout + except Exception as e: + raise e + + lines = output.strip().split("\n") + cli_info["description"] = "" + first = lines.pop(0) + if " - " in first: + cli_info["description"] = first.split(" - ")[1] + + # Handle descriptions that contain newlines + while (line := lines.pop(0)) and not ":" in line: + if len(line) > 0: + cli_info["description"] += f" {line}" + + for i, word in enumerate(lines): + if not ":" in word: + continue + + word = word.strip().split(": ", 1) + if len(word) < 2: + continue + + word[0] = word[0].lower() + if "installed" in word[0]: + word[1] = word[1].replace("?", " ") + cli_info[word[0]] = word[1] + + self.cli_info = cli_info + return cli_info + + def __init__(self, columns): + self.info = { + "name": columns[0], + "id": columns[1], + "version": columns[2], + "branch": columns[3], + "arch": columns[4], + "origin": columns[5], + "installation": columns[6], + "ref": columns[7], + "installed_size": columns[8], + "options": columns[9], + } + self.is_runtime = "runtime" in self.info["options"] + self.data_path = f"{home}/.var/app/{self.info["id"]}" + self.data_size = -1 + self.cli_info = None + installation = self.info["installation"] + if len(i := installation.split(' ')) > 1: + self.info["installation"] = i[1].replace("(", "").replace(")", "") + else: + self.info["installation"] = installation + + self.is_eol = "eol=" in self.info["options"] + self.dependant_runtime = None + self.failed_app_run = None + self.failed_mask = None + self.failed_uninstall = None + self.app_row = None + + try: + self.is_masked = self.info["id"] in HostInfo.masks[self.info["installation"]] + except KeyError: + self.is_masked = False + + try: + self.is_pinned = f"runtime/{self.info['ref']}" in HostInfo.pins[self.info["installation"]] + except KeyError: + self.is_pinned = False + + try: + self.icon_path = ( + icon_theme.lookup_icon( + self.info["id"], None, 512, 1, direction, 0 + ) + .get_file() + .get_path() + ) + except GLib.GError as e: + print(f"Minor error in looking up icon for {self.info['id']}", e) + self.icon_path = None + + +class Remote: + def __init__(self, name, title, disabled): + self.name = name + self.title = title + self.disabled = disabled + if title == "" or title == "-": + self.title = name + +class HostInfo: + home = home + clipboard = Gdk.Display.get_default().get_clipboard() + main_window = None + snapshots_path = f"{home}/.var/app/io.github.flattool.Warehouse/data/Snapshots/" + + # Get all possible installation icon theme dirs + output = subprocess.run( + ['flatpak-spawn', '--host', + 'flatpak', '--installations'], + text=True, + capture_output=True, + ).stdout + lines = output.strip().split("\n") + for i in lines: + icon_theme.add_search_path(f"{i}/exports/share/icons") + + flatpaks = [] + id_to_flatpak = {} + ref_to_flatpak = {} + remotes = {} + installations = [] + masks = {} + pins = {} + dependant_runtime_refs = [] + @classmethod + def get_flatpaks(this, callback=None): + # Callback is a function to run after the host flatpaks are found + this.flatpaks.clear() + this.id_to_flatpak.clear() + this.ref_to_flatpak.clear() + this.remotes.clear() + this.installations.clear() + this.masks.clear() + this.pins.clear() + this.dependant_runtime_refs.clear() + + def thread(task, *args): + + # Remotes + def remote_info(installation): + cmd = ['flatpak-spawn', '--host', + 'flatpak', 'remotes', '--columns=name,title,options', '--show-disabled'] + if installation == "user" or installation == "system": + cmd.append(f"--{installation}") + else: + cmd.append(f"--installation={installation}") + output = subprocess.run( + cmd, text=True, + capture_output=True, + ).stdout + lines = output.strip().split("\n") + remote_list = [] + if lines[0] != '': + for line in lines: + line = line.split("\t") + remote_list.append(Remote(name=line[0], title=line[1], disabled=(len(line) == 3) and "disabled" in line[2])) + this.remotes[installation] = remote_list + + # Masks + cmd = ['flatpak-spawn', '--host', + 'flatpak', 'mask',] + if installation == "user" or installation == "system": + cmd.append(f"--{installation}") + else: + cmd.append(f"--installation={installation}") + output = subprocess.run( + cmd, text=True, + capture_output=True, + ).stdout + lines = output.strip().replace(" ", "").split("\n") + if lines[0] != '': + this.masks[installation] = lines + + # Pins + cmd = ['flatpak-spawn', '--host', + 'flatpak', 'pin',] + if installation == "user" or installation == "system": + cmd.append(f"--{installation}") + else: + cmd.append(f"--installation={installation}") + output = subprocess.run( + cmd, text=True, + capture_output=True, + ).stdout + lines = output.strip().replace(" ", "").split("\n") + if lines[0] != '': + this.pins[installation] = lines + + try: + # Installations + # Get all config files for any extra installations + custom_install_config_path = "/run/host/etc/flatpak/installations.d" + if os.path.exists(custom_install_config_path): + for file in os.listdir(custom_install_config_path): + with open(f"{custom_install_config_path}/{file}", "r") as f: + for line in f: + if line.startswith("[Installation"): + # Get specifically the installation name itself + this.installations.append(line.replace("[Installation \"", "").replace("\"]", "").strip()) + + this.installations.append("user") + this.installations.append("system") + for i in this.installations: + remote_info(i) + remote_info("user") + remote_info("system") + + # Packages + output = subprocess.run( + ['flatpak-spawn', '--host', 'flatpak', 'list', + '--columns=name,application,version,branch,arch,origin,installation,ref,size,options'], + text=True, check=True, + capture_output=True, + ).stdout + lines = output.strip().split("\n") + for i in lines: + package = Flatpak(i.split("\t")) + this.flatpaks.append(package) + this.id_to_flatpak[package.info["id"]] = package + this.ref_to_flatpak[package.info["ref"]] = package + + # Dependant Runtimes + output = subprocess.run( + ['flatpak-spawn', '--host', + 'flatpak', 'list', '--columns=runtime,ref'], + text=True, check=True, + capture_output=True, + ).stdout + lines = output.split("\n") + for index, line in enumerate(lines): + split_line = line.split("\t") + if len(split_line) < 2 or split_line[0] == '': + continue + + package = this.flatpaks[index] + if package.is_runtime: + continue + + runtime = split_line[0] + package.dependant_runtime = this.ref_to_flatpak[runtime] + if not runtime in this.dependant_runtime_refs: + this.dependant_runtime_refs.append(runtime) + + except subprocess.CalledProcessError as cpe: + this.main_window.toast_overlay.add_toast(ErrorToast(_("Could not load packages"), cpe.stderr).toast) + except Exception as e: + this.main_window.toast_overlay.add_toast(ErrorToast(_("Could not load packages"), str(e)).toast) + + Gio.Task.new(None, None, callback).run_in_thread(thread) diff --git a/src/install_page/file_install_dialog.blp b/src/install_page/file_install_dialog.blp new file mode 100644 index 0000000..847b496 --- /dev/null +++ b/src/install_page/file_install_dialog.blp @@ -0,0 +1,41 @@ +using Gtk 4.0; +using Adw 1; + +template $FileInstallDialog : Adw.Dialog { + follows-content-size: true; + Adw.ToolbarView { + [top] + Adw.HeaderBar { + show-start-title-buttons: false; + show-end-title-buttons: false; + [start] + Button cancel_button { + label: _("Cancel"); + } + [end] + Button apply_button { + styles ["suggested-action"] + label: _("Install"); + } + } + ScrolledWindow content_page { + propagate-natural-height: true; + propagate-natural-width: true; + Adw.Clamp { + margin-start: 12; + margin-end: 12; + margin-bottom: 12; + margin-top: 6; + Box { + orientation: vertical; + spacing: 12; + Adw.PreferencesGroup packages_group { + title: _("Review Selection"); + } + $InstallationChooser installation_chooser { + } + } + } + } + } +} diff --git a/src/install_page/file_install_dialog.py b/src/install_page/file_install_dialog.py new file mode 100644 index 0000000..8b367e3 --- /dev/null +++ b/src/install_page/file_install_dialog.py @@ -0,0 +1,46 @@ +from gi.repository import Adw, Gtk + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/install_page/file_install_dialog.ui") +class FileInstallDialog(Adw.Dialog): + __gtype_name__ = "FileInstallDialog" + gtc = Gtk.Template.Child + + packages_group = gtc() + installation_chooser = gtc() + cancel_button = gtc() + apply_button = gtc() + + def generate_list(self): + for file in self.files: + row = Adw.ActionRow(title=file.get_basename()) + row.add_prefix(Gtk.Image(icon_name="flatpak-symbolic")) + self.packages_group.add(row) + + def on_response(self, *args): + self.on_add(self.installation_chooser.get_installation(), self.files) + self.close() + + def __init__(self, parent_page, files, on_add, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.parent_page = parent_page + self.files = files + self.on_add = on_add + + # Apply + self.generate_list() + if len(files) > 1: + self.set_title(_("Install Packages")) + # self.packages_group.set_title(_("Review Packages")) + self.packages_group.set_description(_("The following packages will be installed")) + self.installation_chooser.set_content_strings(_("Packages"), True) + else: + self.set_title(_("Install a Package")) + # self.packages_group.set_title(_("Review Package")) + self.packages_group.set_description(_("The following package will be installed")) + self.installation_chooser.set_content_strings(_("package"), False) + + # Connections + self.cancel_button.connect("clicked", lambda *_: self.close()) + self.apply_button.connect("clicked", self.on_response) diff --git a/src/install_page/install_page.blp b/src/install_page/install_page.blp new file mode 100644 index 0000000..a1a1e51 --- /dev/null +++ b/src/install_page/install_page.blp @@ -0,0 +1,107 @@ +using Gtk 4.0; +using Adw 1; + +template $InstallPage : Adw.BreakpointBin { + width-request: 1; + height-request: 1; + + Adw.Breakpoint break_point { + condition ("max-width: 600") + + setters { + multi_view.layout: skinny; + } + } + + Adw.NavigationPage { + title: _("Install Packages"); + Adw.ToastOverlay toast_overlay { + Stack status_stack { + Adw.ToolbarView loading_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + Adw.ToolbarView installing_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + Adw.MultiLayoutView multi_view { + Adw.Layout wide { + Adw.NavigationSplitView split_view { + sidebar-width-fraction: 0.5; + max-sidebar-width: 999999999; + sidebar: + Adw.NavigationPage { + title: _("Select Source"); + Adw.LayoutSlot { + id: "select_page"; + } + } + ; + content: + Adw.NavigationPage { + title: _("Pending Packages"); + Adw.LayoutSlot { + id: "pending_page"; + } + } + ; + } + } + Adw.Layout skinny { + Adw.BottomSheet bottom_sheet { + [content] + Box { + margin-bottom: bind bottom_sheet.bottom-bar-height; + Adw.LayoutSlot { + id: "select_page"; + } + } + [sheet] + Adw.LayoutSlot { + id: "pending_page"; + } + } + } + [select_page] + $SelectPage select_page {} + [pending_page] + $PendingPage pending_page {} + } + } + } + } +} + +Revealer bottom_child { + reveal-child: false; + Box { + margin-top: 12; + margin-bottom: 14; + spacing: 12; + halign: center; + valign: center; + styles ["flat"] + [start] + Image { + icon-name: "flatpak-symbolic"; + icon-size: normal; + } + [center] + Label bottom_label { + label: _("Pending Packages"); + styles ["heading"] + } + [end] + Image { + icon-name: "right-large-symbolic"; + icon-size: normal; + } + } +} diff --git a/src/install_page/install_page.py b/src/install_page/install_page.py new file mode 100644 index 0000000..bb060ac --- /dev/null +++ b/src/install_page/install_page.py @@ -0,0 +1,95 @@ +from gi.repository import Adw, Gtk, GLib +from .host_info import HostInfo +from .select_page import SelectPage +from .pending_page import PendingPage +from .sidebar_button import SidebarButton +from .loading_status import LoadingStatus +from .package_install_worker import PackageInstallWorker +from .error_toast import ErrorToast + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/install_page/install_page.ui") +class InstallPage(Adw.BreakpointBin): + __gtype_name__ = "InstallPage" + gtc = Gtk.Template.Child + + break_point = gtc() + split_view = gtc() + multi_view = gtc() + select_page = gtc() + pending_page = gtc() + status_stack = gtc() + loading_view = gtc() + installing_view = gtc() + bottom_sheet = gtc() + bottom_child = gtc() + bottom_label = gtc() + + # Referred to in the main window + # It is used to determine if a new page should be made or not + # This must be set to the created object from within the class's __init__ method + instance = None + page_name = "install" + + current_installation = "" + current_remote = None + did_error = False + + def start_loading(self): + self.status_stack.set_visible_child(self.loading_view) + self.select_page.start_loading() + self.pending_page.reset() + + def end_loading(self): + self.select_page.end_loading() + self.status_stack.set_visible_child(self.multi_view) + + def install_callback(self): + HostInfo.main_window.refresh_handler() + if not self.did_error: + HostInfo.main_window.toast_overlay.add_toast(Adw.Toast(title=_("Installed Packages"))) + + def install_error_callback(self, user_facing_label, error_message): + self.did_error = True + GLib.idle_add(lambda *_: HostInfo.main_window.toast_overlay.add_toast(ErrorToast(user_facing_label, error_message).toast)) + + def install_packages(self, package_requests): + self.did_error = False + if PackageInstallWorker.install(package_requests, self.installing_status, self.install_callback, self.install_error_callback): + self.status_stack.set_visible_child(self.installing_view) + + def bottom_bar_visual_handler(self, is_added): + total = self.total_added_packages + if total == 0: + self.bottom_child.set_reveal_child(False) + self.bottom_sheet.set_bottom_bar(None) + elif total == 1: + self.bottom_label.set_label(_("{} Pending Package").format(1)) + if is_added: + self.bottom_sheet.set_bottom_bar(self.bottom_child) + self.bottom_child.set_reveal_child(True) + else: + self.bottom_label.set_label(_("{} Pending Packages").format(total)) + + def package_added(self): + self.total_added_packages += 1 + self.bottom_bar_visual_handler(True) + + def package_removed(self): + self.total_added_packages -= 1 + self.bottom_bar_visual_handler(False) + + def __init__(self, main_window, **kwargs): + super().__init__(**kwargs) + self.instance = self + + # Extra Object Creation + self.installing_status = LoadingStatus(_("Installing Packages"), _("This could take a while"), True, PackageInstallWorker.cancel) + self.total_added_packages = 0 + + # Connections + + # Apply + self.select_page.results_page.pending_page = self.pending_page + self.select_page.results_page.install_page = self + self.loading_view.set_content(LoadingStatus(_("Loading Installation Options"), _("This should only take a moment"))) + self.installing_view.set_content(self.installing_status) diff --git a/src/install_page/pending_page.blp b/src/install_page/pending_page.blp new file mode 100644 index 0000000..2c0aae5 --- /dev/null +++ b/src/install_page/pending_page.blp @@ -0,0 +1,41 @@ +using Gtk 4.0; +using Adw 1; + +template $PendingPage : Adw.NavigationPage { + title: _("Pending Packages"); + Stack stack { + Adw.ToolbarView none_pending { + [top] + Adw.HeaderBar { + } + Adw.StatusPage { + icon-name: "flatpak-symbolic"; + title: _("Add Packages"); + description: _("Packages queued to install will show up here"); + } + } + Adw.ToolbarView main_view { + [top] + Adw.HeaderBar { + } + Adw.PreferencesPage preferences_page { + } + [bottom] + ActionBar pending_action_bar { + revealed: true; + [center] + Button install_button { + margin-top: 3; + margin-bottom: 3; + sensitive: bind pending_action_bar.revealed; + styles ["pill", "suggested-action"] + Adw.ButtonContent { + can-shrink: true; + icon-name: "arrow-pointing-at-line-down-symbolic"; + label: _("Install"); + } + } + } + } + } +} diff --git a/src/install_page/pending_page.py b/src/install_page/pending_page.py new file mode 100644 index 0000000..3e3710e --- /dev/null +++ b/src/install_page/pending_page.py @@ -0,0 +1,128 @@ +from gi.repository import Adw, Gtk +from .host_info import HostInfo +from .result_row import ResultRow + +class AddedGroup(Adw.PreferencesGroup): + __gtype_name__ = "AddedGroup" + + def add_row(self, row): + self.rows.append(row) + self.add(row) + + def rem_row(self, row): + if row in self.rows: + self.rows.remove(row) + self.remove(row) + + def remove_all(self, *args): + while len(self.rows) > 0 and (row := self.rows[0]): + row.activate() + + def __init__(self, remote, installation, **kwargs): + super().__init__(**kwargs) + + self.remote = remote + self.installation = installation + self.rows = [] + + self.set_title(f"{remote.title}") + self.set_description(_("Installation: {}").format(installation)) + + remove_all = Gtk.Button( + child=Adw.ButtonContent( + icon_name="list-remove-all-symbolic", + label=_("Remove All"), + ), + valign = Gtk.Align.CENTER, + ) + remove_all.add_css_class("flat") + remove_all.connect("clicked", self.remove_all) + self.set_header_suffix(remove_all) + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/install_page/pending_page.ui") +class PendingPage(Adw.NavigationPage): + __gtype_name__ = "PendingPage" + gtc = Gtk.Template.Child + + stack = gtc() + main_view = gtc() + none_pending = gtc() + preferences_page = gtc() + install_button = gtc() + + def add_package_row(self, row): + self.added_packages.append(row.package) + row.set_state(ResultRow.PackageState.SELECTED) + key = f"{row.package.remote}<>{row.package.installation}" + added_row = ResultRow(row.package, ResultRow.PackageState.ADDED, row.origin_list_box) + group = None + try: + group = self.groups[key] + group.add_row(added_row) + except KeyError: + group = AddedGroup(added_row.package.remote, added_row.package.installation) + group.add_row(added_row) + self.groups[key] = group + self.preferences_page.add(group) + + added_row.connect("activated", self.remove_package_row, group) + self.stack.set_visible_child(self.main_view) + + def remove_package_row(self, row, group): + # row.origin_row.set_state(ResultRow.PackageState.NEW) + for item in row.origin_list_box: + if item.state == ResultRow.PackageState.SELECTED and item.package.is_similar(row.package): + item.set_state(ResultRow.PackageState.NEW) + break + + group.rem_row(row) + if row.package in self.added_packages: + self.added_packages.remove(row.package) + + if len(group.rows) == 0: + key = f"{row.package.remote}<>{row.package.installation}" + self.groups.pop(key, None) + self.preferences_page.remove(group) + + if len(self.added_packages) == 0: + self.stack.set_visible_child(self.none_pending) + + install_page = HostInfo.main_window.pages[HostInfo.main_window.install_row] + install_page.package_removed() + + def on_install(self, *args): + package_requests = [] + for key, group in self.groups.items(): + item = { + "remote": group.remote.name, + "installation": group.installation, + "package_names": [], + "extra_flags": [], + } + for row in group.rows: + item['package_names'].append(row.package.app_id) + + package_requests.append(item) + + install_page = HostInfo.main_window.pages[HostInfo.main_window.install_row] + install_page.install_packages(package_requests) + + def reset(self): + for key, group in self.groups.items(): + self.preferences_page.remove(group) + + self.groups.clear() + self.added_packages.clear() + self.stack.set_visible_child(self.none_pending) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.groups = {} # remote<>installation: adw.preference_group + self.added_packages = [] + + # Connections + self.install_button.connect("clicked", self.on_install) + + # Apply diff --git a/src/install_page/result_row.blp b/src/install_page/result_row.blp new file mode 100644 index 0000000..d7928a2 --- /dev/null +++ b/src/install_page/result_row.blp @@ -0,0 +1,49 @@ +using Gtk 4.0; +using Adw 1; + +template $ResultRow : Adw.ActionRow { + activatable: true; + title: "No title set"; + subtitle: "No subtitle set"; + tooltip-text: _("Add Package to Queue"); + + Box { + orientation: vertical; + valign: center; + spacing: 4; + margin-end: 4; + Label version_label { + styles ["subtitle"] + label: ""; + justify: right; + halign: end; + hexpand: true; + wrap: true; + } + Label branch_label { + styles ["subtitle"] + label: ""; + justify: right; + halign: end; + hexpand: true; + wrap: true; + } + } + [suffix] + Image add_image { + icon-name: "plus-large-symbolic"; + } + [suffix] + Image selected_image { + icon-name: "check-plain-symbolic"; + } + [suffix] + Image sub_image { + icon-name: "minus-large-symbolic"; + } + [suffix] + Image installed_image { + icon-name: "selection-mode-symbolic"; + styles ["success"] + } +} diff --git a/src/install_page/result_row.py b/src/install_page/result_row.py new file mode 100644 index 0000000..df7c4c0 --- /dev/null +++ b/src/install_page/result_row.py @@ -0,0 +1,71 @@ +from gi.repository import Adw, Gtk, GLib +from enum import Enum +import os + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/install_page/result_row.ui") +class ResultRow(Adw.ActionRow): + __gtype_name__ = "ResultRow" + gtc = Gtk.Template.Child + + version_label = gtc() + branch_label = gtc() + add_image = gtc() + sub_image = gtc() + selected_image = gtc() + installed_image = gtc() + + class PackageState(Enum): + NEW = 0 + SELECTED = 1 + ADDED = 2 + INSTALLED = 3 + + def idle_stuff(self): + self.set_title(GLib.markup_escape_text(self.package.name)) + self.set_subtitle(self.package.app_id) + self.version_label.set_label(GLib.markup_escape_text(self.package.version)) + self.branch_label.set_label(GLib.markup_escape_text(self.package.branch)) + self.version_label.set_visible(len(self.version_label.get_label()) != 0) + self.branch_label.set_visible(len(self.branch_label.get_label()) != 0) + + def set_state(self, state): + if state == self.state: + return + + self.state = state + self.add_image.set_visible(False) + self.sub_image.set_visible(False) + self.selected_image.set_visible(False) + self.installed_image.set_visible(False) + match state: + case self.PackageState.NEW: + self.set_sensitive(True) + self.set_tooltip_text(_("Add Package to Queue")) + self.add_image.set_visible(True) + case self.PackageState.SELECTED: + self.set_sensitive(False) + self.set_tooltip_text(_("Package has been Added to Queue")) + self.selected_image.set_visible(True) + case self.PackageState.ADDED: + self.set_sensitive(True) + self.set_tooltip_text(_("Remove Package from Queue")) + self.sub_image.set_visible(True) + case self.PackageState.INSTALLED: + self.set_sensitive(False) + self.set_tooltip_text(_("This Package is Already Installed")) + self.installed_image.set_visible(True) + + def __init__(self, package, package_state, origin_list_box, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.state = None + self.package = package + self.origin_list_box = origin_list_box + + # Connections + + # Apply + GLib.idle_add(self.idle_stuff) + self.set_state(package_state) + diff --git a/src/install_page/results_page.blp b/src/install_page/results_page.blp new file mode 100644 index 0000000..c22cb99 --- /dev/null +++ b/src/install_page/results_page.blp @@ -0,0 +1,54 @@ +using Gtk 4.0; +using Adw 1; + +template $ResultsPage : Adw.NavigationPage { + title: _("Search a Remote"); + Adw.ToolbarView { + [top] + Adw.HeaderBar {} + [top] + Adw.Clamp { + maximum-size: 577; + margin-top: 3; + margin-bottom: 3; + margin-start: 6; + margin-end: 6; + SearchEntry search_entry { + search-delay: 500; + halign: fill; + // hexpand: true; + placeholder-text: _("Search for Packages"); + } + } + Stack stack { + Adw.StatusPage new_search { + icon-name: "loupe-large-symbolic"; + title: _("Search for Flatpaks"); + description: _("Search for Flatpaks you want to install"); + } + Adw.StatusPage too_many { + icon-name: "error-symbolic"; + title: _("Too Many Results"); + description: _("Try being more specific with your search"); + } + ScrolledWindow results_view { + Adw.Clamp { + ListBox results_list { + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + styles ["boxed-list"] + selection-mode: none; + valign: start; + } + } + } + Adw.StatusPage no_results { + icon-name: "loupe-large-symbolic"; + title: _("No Results Found"); + description: _("Try a different search term"); + } + } + } +} diff --git a/src/install_page/results_page.py b/src/install_page/results_page.py new file mode 100644 index 0000000..7d48448 --- /dev/null +++ b/src/install_page/results_page.py @@ -0,0 +1,142 @@ +from gi.repository import Adw, Gtk, GLib, Gio +from .host_info import HostInfo +from .result_row import ResultRow +from .loading_status import LoadingStatus +from .error_toast import ErrorToast +import subprocess + +class AddedPackage: + def __eq__(self, other): + return ( + self.name == other.name + and self.app_id == other.app_id + and self.branch == other.branch + and self.version == other.version + and self.remote == other.remote + and self.installation == other.installation + ) + + def is_similar(self, other): + return ( + self.app_id == other.app_id + and self.branch == other.branch + and self.version == other.version + ) + + def __init__(self, name, app_id, branch, version, remote, installation): + self.name = name + self.app_id = app_id + self.branch = branch + self.version = version + self.remote = remote + self.installation = installation + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/install_page/results_page.ui") +class ResultsPage(Adw.NavigationPage): + __gtype_name__ = "ResultsPage" + gtc = Gtk.Template.Child + + search_entry = gtc() + results_list = gtc() + stack = gtc() + new_search = gtc() + too_many = gtc() + results_view= gtc() + no_results = gtc() + + def show_remote(self, row, remote, installation, nav_view=None): + self.remote = remote + self.installation = installation + self.set_title(_("Search {}").format(remote.title)) + self.search_entry.set_text("") + self.search_entry.grab_focus() + if nav_view: + nav_view.push(self) + + def add_package_row(self, row): + self.pending_page.add_package_row(row) + if not self.install_page is None: + self.install_page.package_added() + + def on_search(self, *args): + self.packages.clear() + self.stack.set_visible_child(self.loading) + self.results_list.remove_all() + search_text = self.search_entry.get_text() + if search_text == "": + self.stack.set_visible_child(self.new_search) + return + + def thread(*args): + installation = "" + if self.installation == "user" or self.installation == "system": + installation = f"--{self.installation}" + else: + installation = f"--installation={self.installation}" + + try: + output = subprocess.run( + ['flatpak-spawn', '--host', 'flatpak', 'search', '--columns=all', installation, self.search_entry.get_text()], + check=True, text=True, capture_output=True + ).stdout.split('\n') + if len(output) > 100: + GLib.idle_add(lambda *_: self.stack.set_visible_child(self.too_many)) + return + + for line in output: + line = line.strip() + + info = line.split('\t') + if len(info) != 6: + continue + + remotes = info[5].split(',') + if not self.remote.name in remotes: + continue + + package = AddedPackage(info[0], info[2], info[4], info[3], self.remote, self.installation) + row = ResultRow(package, ResultRow.PackageState.NEW, self.results_list) + for item in self.pending_page.added_packages: + if package.is_similar(item): + row.set_state(ResultRow.PackageState.SELECTED) + + if package.app_id in HostInfo.id_to_flatpak: + installed_package = HostInfo.id_to_flatpak[package.app_id] + if installed_package.info["id"] == package.app_id and installed_package.info["branch"] == package.branch: + row.set_state(ResultRow.PackageState.INSTALLED) + + row.connect("activated", self.add_package_row) + self.packages.append(package) + GLib.idle_add(lambda *_, _row=row: self.results_list.append(_row)) + + if len(self.packages) > 0: + GLib.idle_add(lambda *_: self.stack.set_visible_child(self.results_view)) + else: + GLib.idle_add(lambda *_: self.stack.set_visible_child(self.no_results)) + + except subprocess.CalledProcessError as cpe: + GLib.idle_add(lambda *_, cpe=cpe: HostInfo.main_window.toast_overlay.add_toast(ErrorToast("Could not search for package", cpe.stderr).toast)) + GLib.idle_add(lambda *_: self.install_page.select_page.nav_view.pop()) + + Gio.Task().run_in_thread(thread) + + def on_back(self, *args): + self.results_list.remove_all() + self.stack.set_visible_child(self.new_search) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.remote = None + self.installation = None + self.packages = [] + self.pending_page = None + self.loading = LoadingStatus(_("Searching"), _("This should only take a moment")) + self.install_page = None + + # Connections + self.search_entry.connect("search-changed", self.on_search) + + # Apply + self.stack.add_child(self.loading) diff --git a/src/install_page/select_page.blp b/src/install_page/select_page.blp new file mode 100644 index 0000000..119ac22 --- /dev/null +++ b/src/install_page/select_page.blp @@ -0,0 +1,50 @@ +using Gtk 4.0; +using Adw 1; + +template $SelectPage : Adw.NavigationPage { + title: _("Install Packages"); + + Adw.ToastOverlay toast_overlay { + Adw.NavigationView nav_view { + Adw.NavigationPage select_nav_page { + title: _("Install Packages"); + Adw.ToolbarView { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + Adw.PreferencesPage { + Adw.PreferencesGroup remotes_group { + title: _("Search in a Remote"); + description: _("Choose a remote to search for new packages"); + } + Adw.PreferencesGroup no_remotes { + title: _("Online Searches Disabled"); + description: _("Your system has no remotes added to search from"); + visible: bind remotes_group.visible inverted; + Adw.ActionRow add_remote_row { + title: _("Add a Remote"); + subtitle: _("Add a remote to your system to enable online searching"); + activatable: true; + [suffix] + Image { + icon-name: "right-large-symbolic"; + } + } + } + Adw.PreferencesGroup local_group { + title: _("Add Packages"); + description: _("Install packages from files on your system"); + Adw.ButtonRow open_row { + title: _("Open Files"); + start-icon-name: "folder-open-symbolic"; + } + } + } + } + } + $ResultsPage results_page {} + } + } +} diff --git a/src/install_page/select_page.py b/src/install_page/select_page.py new file mode 100644 index 0000000..c34f7e4 --- /dev/null +++ b/src/install_page/select_page.py @@ -0,0 +1,91 @@ +from gi.repository import Adw, Gtk, GLib, Gio +from .host_info import HostInfo +from .error_toast import ErrorToast +from .results_page import ResultsPage +from .sidebar_button import SidebarButton +from .file_install_dialog import FileInstallDialog + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/install_page/select_page.ui") +class SelectPage(Adw.NavigationPage): + __gtype_name__ = "SelectPage" + gtc = Gtk.Template.Child + + nav_view = gtc() + results_page = gtc() + remotes_group = gtc() + add_remote_row = gtc() + open_row = gtc() + + def start_loading(self): + self.nav_view.pop() + for row in self.remote_rows: + self.remotes_group.remove(row) + self.remote_rows.clear() + + def end_loading(self): + for installation, remotes in HostInfo.remotes.items(): + for remote in remotes: + if remote.disabled: + continue + + row = Adw.ActionRow(title=remote.title, subtitle=_("Installation: {}").format(installation), activatable=True) + row.add_suffix(Gtk.Image(icon_name="right-large-symbolic")) + row.connect("activated", self.results_page.show_remote, remote, installation, self.nav_view) + self.remotes_group.add(row) + self.remote_rows.append(row) + + self.remotes_group.set_visible(len(self.remote_rows) != 0) + + def local_install_apply_callback(self, installation, file_names): + install_page = HostInfo.main_window.pages[HostInfo.main_window.install_row] + requests = [] + for file in file_names: + # sadly flatpak doesn't support multiple local installs in one command :( + requests.append({ + "remote": "local_file", + "installation": installation, + "package_names": [file.get_path()], + "extra_flags": [], + }) + + install_page.install_packages(requests) + + def file_dialog_handler(self, files): + FileInstallDialog(self, files, self.local_install_apply_callback).present(HostInfo.main_window) + + def file_choose_callback(self, object, result): + try: + files = object.open_multiple_finish(result) + if not files: + HostInfo.main_window.toast_overlay.add_toast(ErrorToast(_("Could not add files"), _("No files were found to install"))) + return + + self.file_dialog_handler(files) + + except GLib.GError as gle: + if not (gle.domain == "gtk-dialog-error-quark" and gle.code == 2): + HostInfo.main_window.toast_overlay.add_toast(ErrorToast(_("Could not add files"), str(gle)).toast) + + def on_open(self, *args): + file_filter = Gtk.FileFilter(name=_("Flatpaks")) + file_filter.add_suffix("flatpak") + file_filter.add_suffix("flatpakref") + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(file_filter) + file_chooser = Gtk.FileDialog() + file_chooser.set_filters(filters) + file_chooser.set_default_filter(file_filter) + file_chooser.open_multiple(HostInfo.main_window, None, self.file_choose_callback) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.remote_rows = [] + + # Connections + self.add_remote_row.connect("activated", lambda *_: HostInfo.main_window.activate_row(HostInfo.main_window.remotes_row)) + self.nav_view.connect("popped", self.results_page.on_back) + self.open_row.connect("activated", self.on_open) + + # Apply diff --git a/src/main.py b/src/main.py index 9beaed5..3f83190 100644 --- a/src/main.py +++ b/src/main.py @@ -26,57 +26,45 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, Gio, Adw, GLib +from .host_info import HostInfo from .window import WarehouseWindow -from .remotes_window import RemotesWindow -from .orphans_window import OrphansWindow -from .filter_window import FilterWindow -from .search_install_window import SearchInstallWindow from .const import Config -from .common import myUtils - +from .error_toast import ErrorToast class WarehouseApplication(Adw.Application): """The main application singleton class.""" - + troubleshooting = "OS: {os}\nWarehouse version: {wv}\nGTK: {gtk}\nlibadwaita: {adw}\nApp ID: {app_id}\nProfile: {profile}\nLanguage: {lang}" version = Config.VERSION - + def __init__(self): super().__init__( application_id="io.github.flattool.Warehouse", flags=Gio.ApplicationFlags.DEFAULT_FLAGS, ) - self.create_action("quit", lambda *_: self.quit(), ["q"]) self.create_action("about", self.on_about_action) self.create_action("preferences", self.on_preferences_action) - self.create_action("search", self.on_search_action, ["f"]) - self.create_action("manage-data-folders", self.manage_data_shortcut) - self.create_action( - "toggle-batch-mode", - self.batch_mode_shortcut, - ["b", "Return"], - ) - self.create_action( - "toggle-batch-mode-keypad", self.batch_mode_shortcut, ["KP_Enter"] - ) # This action is not added to the shortcuts window - self.create_action( - "manage-data-folders", self.manage_data_shortcut, ["d"] - ) - self.create_action( - "refresh-list", self.refresh_list_shortcut, ["r", "F5"] - ) - self.create_action( - "show-remotes-window", self.show_remotes_shortcut, ["m"] - ) - self.create_action("set-filter", self.filters_shortcut, ["t"]) - self.create_action("install-from-file", self.install_from_file, ["o"]) - self.create_action("open-menu", self.main_menu_shortcut, ["F10"]) - self.create_action( - "open-search-install", self.open_search_install, ["i"] - ) - + self.create_action("quit", lambda *_: self.quit(), ["q"]) + self.create_action("open-menu", lambda *_: self.props.active_window.main_menu.popup(), ["F10"]) + self.create_action("refresh", lambda *_: self.props.active_window.refresh_handler(), ["r", "F5"]) + self.create_action("open-files", self.on_open_files_shortcut, ["o"]) + + self.create_action("show-packages-page", lambda *_: self.props.active_window.switch_page_shortcut_handler("p"), ["p"]) + self.create_action("show-remotes-page", lambda *_: self.props.active_window.switch_page_shortcut_handler("m"), ["m"]) + self.create_action("show-user-data-page", lambda *_: self.props.active_window.switch_page_shortcut_handler("d"), ["d"]) + self.create_action("show-snapshots-page", lambda *_: self.props.active_window.switch_page_shortcut_handler("s"), ["s"]) + self.create_action("show-install-page", lambda *_: self.props.active_window.switch_page_shortcut_handler("i"), ["i"]) + + self.create_action("toggle-select-mode", self.on_toggle_select_mode_shortcut, ["b", "Return"]) + self.create_action("toggle-selection-kp-enter", self.on_toggle_select_mode_shortcut, ["KP_Enter"]) # Doesn't show in the shortcuts window + self.create_action("search-mode", self.on_search_mode_shortcut, ["f"]) + self.create_action("filter", self.on_filter_shortcut, ["t"]) + self.create_action("new", self.on_new_shortcut, ["n"]) + self.create_action("active-data-view", lambda *_: self.on_data_view_shortcut(True), ["1"]) + self.create_action("leftover-data-view", lambda *_: self.on_data_view_shortcut(False), ["2"]) + self.is_dialog_open = False - + gtk_version = ( str(Gtk.MAJOR_VERSION) + "." @@ -93,7 +81,7 @@ class WarehouseApplication(Adw.Application): ) os_string = GLib.get_os_info("NAME") + " " + GLib.get_os_info("VERSION") lang = GLib.environ_getenv(GLib.get_environ(), "LANG") - + self.troubleshooting = self.troubleshooting.format( os=os_string, wv=self.version, @@ -103,62 +91,101 @@ class WarehouseApplication(Adw.Application): app_id=self.get_application_id(), lang=lang, ) - - self.my_utils = myUtils(self) - total = 0 - for rem in self.my_utils.get_host_remotes(): - if self.my_utils.get_install_type(rem[7]) != "disabled": - total += 1 - if total < 1: - self.lookup_action(f"open-search-install").set_enabled(False) - - def open_search_install(self, widget, _): - SearchInstallWindow(self.props.active_window) - - def batch_mode_shortcut(self, widget, _): - button = self.props.active_window.batch_mode_button - button.set_active(not button.get_active()) - - def manage_data_shortcut(self, widget, _): - OrphansWindow(self.props.active_window) - - def refresh_list_shortcut(self, widget, _): - self.props.active_window.refresh_list_of_flatpaks(widget) - - def show_remotes_shortcut(self, widget, _): - RemotesWindow(self.props.active_window) - - def filters_shortcut(self, widget, _): - FilterWindow(self.props.active_window) - - def main_menu_shortcut(self, widget, _): + + def on_open_files_shortcut(self, *args): window = self.props.active_window - window.main_menu.set_active(True) - - def file_callback(self, object, result): - window = self.props.active_window - try: - file = object.open_finish(result) - window.install_file(file.get_path()) - except GLib.GError: - pass - - def install_from_file(self, widget, _a): - window = self.props.active_window - - filter = Gtk.FileFilter(name=_("Flatpaks")) - filter.add_suffix("flatpak") - filter.add_suffix("flatpakref") + + def file_choose_callback(object, result): + try: + files = object.open_multiple_finish(result) + if not files: + window.toast_overlay.add_toast(ErrorToast(_("Could not add files"), _("No files were found")).toast) + return + + window.on_file_drop(None, files, None, None) + except GLib.GError as gle: + if not (gle.domain == "gtk-dialog-error-quark" and gle.code == 2): + window.toast_overlay.add_toast(ErrorToast(_("Could not add files"), str(gle)).toast) + + file_filter = Gtk.FileFilter(name=_("Flatpaks & Remotes")) + file_filter.add_suffix("flatpak") + file_filter.add_suffix("flatpakref") + file_filter.add_suffix("flatpakrepo") filters = Gio.ListStore.new(Gtk.FileFilter) - filters.append(filter) + filters.append(file_filter) file_chooser = Gtk.FileDialog() file_chooser.set_filters(filters) - file_chooser.set_default_filter(filter) - file_chooser.open(window, None, self.file_callback) - + file_chooser.set_default_filter(file_filter) + file_chooser.open_multiple(window, None, file_choose_callback) + + def on_toggle_select_mode_shortcut(self, *args): + try: + button = self.props.active_window.stack.get_visible_child().select_button + button.set_active(not button.get_active()) + except AttributeError: + pass + + def on_search_mode_shortcut(self, *args): + try: + button = self.props.active_window.stack.get_visible_child().search_button + button.set_active(True) + except AttributeError: + pass + + def on_filter_shortcut(self, *args): + try: + button = self.props.active_window.stack.get_visible_child().filter_button + button.set_active(not button.get_active()) + except AttributeError: + pass + + try: + button = self.props.active_window.stack.get_visible_child().sort_button + button.set_active(True) + except AttributeError: + pass + + try: + button = self.props.active_window.stack.get_visible_child().show_disabled_button + if button.get_visible(): + button.set_active(not button.get_active()) + except AttributeError: + pass + + def on_new_shortcut(self, *args): + page = self.props.active_window.stack.get_visible_child() + try: + page.new_custom_handler() + except AttributeError: + pass + + try: + page.on_new() + except AttributeError: + pass + + def on_delete_shortcut(self, *args): + page = self.props.active_window.stack.get_visible_child() + try: + if not page.select_button.get_active(): + return + + page.select_trash_handler() + except AttributeError: + pass + + def on_data_view_shortcut(self, is_active): + page = self.props.active_window.stack.get_visible_child() + try: + adp = page.adp + ldp = page.ldp + page.stack.set_visible_child(adp if is_active else ldp) + except AttributeError: + pass + def do_activate(self): """Called when the application is activated. - + We raise the application's main window, creating it if necessary. """ @@ -166,7 +193,7 @@ class WarehouseApplication(Adw.Application): if not win: win = WarehouseWindow(application=self) win.present() - + def on_about_action(self, widget, _a): """Callback for the app.about action.""" about = Adw.AboutDialog( @@ -207,23 +234,17 @@ class WarehouseApplication(Adw.Application): ], ) about.present(self.props.active_window) - + def on_preferences_action(self, widget, _): """Callback for the app.preferences action.""" print("app.preferences action activated") - - def on_search_action(self, widget, _): - self.props.active_window.search_bar.set_search_mode( - not self.props.active_window.search_bar.get_search_mode() - ) - + def create_action(self, name, callback, shortcuts=None): """Add an application action. - + Args: name: the name of the action - callback: the function to be called when the action is - activated + callback: the function to be called when the action is activated shortcuts: an optional list of accelerators """ action = Gio.SimpleAction.new(name, None) @@ -231,8 +252,7 @@ class WarehouseApplication(Adw.Application): self.add_action(action) if shortcuts: self.set_accels_for_action(f"app.{name}", shortcuts) - - + def main(version): """The application's entry point.""" app = WarehouseApplication() diff --git a/src/main_window/window.blp b/src/main_window/window.blp new file mode 100644 index 0000000..7774db0 --- /dev/null +++ b/src/main_window/window.blp @@ -0,0 +1,161 @@ +using Gtk 4.0; +using Adw 1; + +template $WarehouseWindow: Adw.ApplicationWindow { + title: "Warehouse"; + default-width: 921; + default-height: 450; + Adw.Breakpoint main_breakpoint { + condition ("min-width: 865") + setters { + main_split.collapsed: false; + main_split.max-sidebar-width: 999999999; + } + } + content: + Adw.ToastOverlay toast_overlay { + Overlay { + [overlay] + Revealer file_drop_revealer { + can-target: false; + transition-type: crossfade; + Adw.StatusPage file_drop_view { + icon-name: "folder-open-symbolic"; + title: _("Drop to Open"); + description: _("Install Flatpaks or Add a Remote"); + styles ["drag-overlay-status-page"] + } + } + Adw.OverlaySplitView main_split { + collapsed: true; + show-sidebar: true; + sidebar-width-fraction: 0.2; + min-sidebar-width: 250; + sidebar: + Adw.NavigationPage { + title: "Warehouse"; + Adw.ToolbarView main_toolbar_view { + [top] + Adw.HeaderBar header_bar { + [start] + Button refresh_button { + icon-name: "arrow-circular-top-right-symbolic"; + tooltip-text: _("Refresh List"); + } + [end] + MenuButton main_menu { + icon-name: "open-menu-symbolic"; + tooltip-text: _("Main Menu"); + menu-model: primary_menu; + } + } + content: + ScrolledWindow { + ListBox navigation_row_listbox { + styles ["navigation-sidebar"] + Box packages_row { + margin-top: 12; + margin-bottom: 12; + margin-start: 6; + margin-end: 6; + spacing: 12; + Image icon { + icon-name: "flatpak-symbolic"; + } + Label { + label: _("Packages"); + } + } + Box remotes_row { + margin-top: 12; + margin-bottom: 12; + margin-start: 6; + margin-end: 6; + spacing: 12; + Image { + icon-name: "server-pick-symbolic"; + } + Label { + label: _("Remotes"); + } + } + + Box user_data_row { + margin-top: 12; + margin-bottom: 12; + margin-start: 6; + margin-end: 6; + spacing: 12; + Image { + icon-name: "file-manager-symbolic"; + } + Label { + label: _("User Data"); + } + } + Box snapshots_row { + margin-top: 12; + margin-bottom: 12; + margin-start: 6; + margin-end: 6; + spacing: 12; + Image { + icon-name: "snapshots-alt-symbolic"; + } + Label { + label: _("Snapshots"); + } + } + Box install_row { + margin-top: 12; + margin-bottom: 12; + margin-start: 6; + margin-end: 6; + spacing: 12; + Image { + icon-name: "arrow-pointing-at-line-down-symbolic"; + } + Label { + label: _("Install Packages"); + } + } + } + } + ; + } + } + ; + content: + Stack stack { + } + ; + } + } + } + ; +} + +menu primary_menu { + section { + /*item { + label: _("_Preferences"); + action: "app.preferences"; + }*/ + // item { + // label: _("Refresh List"); + // action: "app.refresh-list"; + // } + item { + label: _("_Open Files"); + action: "app.open-files"; + } + item { + label: _("_Keyboard Shortcuts"); + action: "win.show-help-overlay"; + } + item { + label: _("_About Warehouse"); + action: "app.about"; + } + } +} diff --git a/src/main_window/window.py b/src/main_window/window.py new file mode 100644 index 0000000..e500660 --- /dev/null +++ b/src/main_window/window.py @@ -0,0 +1,254 @@ +# window.py +# +# Copyright 2023 Heliguy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 of the License only. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-only + +from gi.repository import Adw, Gdk, Gio, GLib, Gtk +from .host_info import HostInfo +from .packages_page import PackagesPage +from .remotes_page import RemotesPage +from .user_data_page import UserDataPage +from .snapshot_page import SnapshotPage +from .install_page import InstallPage +from .error_toast import ErrorToast +from .const import Config + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/main_window/window.ui") +class WarehouseWindow(Adw.ApplicationWindow): + __gtype_name__ = "WarehouseWindow" + gtc = Gtk.Template.Child + main_breakpoint = gtc() + toast_overlay = gtc() + file_drop_revealer = gtc() + main_split = gtc() + file_drop_view = gtc() + stack = gtc() + refresh_button = gtc() + main_menu = gtc() + navigation_row_listbox = gtc() + packages_row = gtc() + remotes_row = gtc() + user_data_row = gtc() + snapshots_row = gtc() + install_row = gtc() + + def start_loading(self, *args): + for _, page in self.pages.items(): + if page.instance: + page.instance.start_loading() + + def end_loading(self, *args): + for _, page in self.pages.items(): + if page.instance: + page.instance.end_loading() + + self.refresh_button.set_sensitive(True) + self.refresh_requested = False + self.remove_refresh_lockout("refresh handler direct") + + def do_refresh(self): + self.start_loading() + self.refresh_button.set_sensitive(False) + HostInfo.get_flatpaks(callback=self.end_loading) + + def refresh_handler(self, *args): + if len(self.refresh_lockouts) == 0: + self.add_refresh_lockout("refresh handler direct") + self.do_refresh() + elif "refresh handler direct" in self.refresh_lockouts: + return + else: + self.refresh_requested = True + + def add_refresh_lockout(self, reason): + self.refresh_lockouts.append(reason) + self.refresh_button.set_sensitive(False) + + def remove_refresh_lockout(self, reason): + if reason in self.refresh_lockouts: + self.refresh_lockouts.remove(reason) + + if len(self.refresh_lockouts) == 0: + if self.refresh_requested: + self.do_refresh() + else: + self.refresh_button.set_sensitive(True) + + def navigation_handler(self, _, row): + row = row.get_child() + page = self.pages[row] + self.stack.set_visible_child(page) + self.settings.set_string("page-shown", page.page_name) + if self.main_split.get_collapsed(): + self.main_split.set_show_sidebar(False) + + def activate_row(self, nav_row): + idx = 0 + while row := self.navigation_row_listbox.get_row_at_index(idx): + idx += 1 + if row.get_child() is nav_row: + row.activate() + nav_row.grab_focus() + break + + def show_saved_page(self): + page_to_show = self.settings.get_string("page-shown") + page_found = False + for row, page in self.pages.items(): + self.stack.add_child(page) + + if page.page_name == page_to_show: + page_found = True + self.activate_row(row) + + if not page_found: + self.navigation_row_listbox.get_row_at_index(0).activate() + + def on_file_drop(self, drop_target, value, x, y): + try: + paks = [] + remotes = [] + for file in value: + path = file.get_path() + if path.endswith(".flatpak") or path.endswith(".flatpakref"): + paks.append(Gio.File.new_for_path(path)) + elif path.endswith(".flatpakrepo"): + remotes.append(path) + else: + dialog = Adw.AlertDialog( + heading=_("Unsupported Filetype"), + body=_("Only .flatpak, .flatpakref, and .flatpakrepo files are supported."), + ) + dialog.add_response("continue", _("OK")) + dialog.present(self) + return + + if len(remotes) > 0 and len(paks) > 0: + dialog = Adw.AlertDialog( + heading=_("Mixed Filetypes"), + body=_("Flatpaks and remotes cannot be installed at the same time."), + ) + dialog.add_css_class("error") + dialog.add_response("continue", _("OK")) + dialog.present(self) + return + + if len(remotes) > 1: + dialog = Adw.AlertDialog( + heading=_("Too Many Remotes"), + body=_("Only one remote at a time is supported."), + ) + dialog.add_response("continue", _("OK")) + dialog.present(self) + return + + if len(remotes) == 1: + # Adding a remote + self.activate_row(self.remotes_row) + remotes_page = self.pages[self.remotes_row] + remotes_page.local_file_handler(remotes[0]) + elif len(paks) > 0: + # Add packages + self.activate_row(self.install_row) + install_page = self.pages[self.install_row] + install_page.select_page.file_dialog_handler(paks) + + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not open files"), str(e)).toast) + + def on_drop_enter(self, *args): + self.main_split.add_css_class("blurred") + self.file_drop_revealer.set_reveal_child(True) + return 1 + + def on_drop_leave(self, *args): + self.main_split.remove_css_class("blurred") + self.file_drop_revealer.set_reveal_child(False) + + def switch_page_shortcut_handler(self, letter): + self.activate_row(self.shortcut_to_pages[letter]) + + def key_handler(self, controller, keyval, keycode, state): + page = self.stack.get_visible_child() + if keyval == Gdk.KEY_BackSpace or keyval == Gdk.KEY_Delete: + try: + if page.select_button.get_active(): + page.on_backspace_handler() + except AttributeError: + pass + elif keyval == Gdk.KEY_Escape: + try: + page.on_escape_handler() + except AttributeError: + pass + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + HostInfo.main_window = self + ErrorToast.main_window = self + self.settings = Gio.Settings.new("io.github.flattool.Warehouse") + self.pages = { + self.packages_row: PackagesPage(main_window=self), + self.remotes_row: RemotesPage(main_window=self), + self.user_data_row: UserDataPage(main_window=self), + self.snapshots_row: SnapshotPage(main_window=self), + self.install_row: InstallPage(main_window=self), + } + self.shortcut_to_pages = { + "p": self.packages_row, + "m": self.remotes_row, + "d": self.user_data_row, + "s": self.snapshots_row, + "i": self.install_row, + } + self.navigation_row_listbox.connect("row-activated", self.navigation_handler) + self.show_saved_page() + self.refresh_lockouts = [] + self.refresh_requested = False + file_drop = Gtk.DropTarget.new(Gdk.FileList, Gdk.DragAction.COPY) + event_controller = Gtk.EventControllerKey() + + # Apply + self.add_controller(file_drop) + self.add_controller(event_controller) + self.settings.bind("window-width", self, "default-width", Gio.SettingsBindFlags.DEFAULT) + self.settings.bind("window-height", self, "default-height", Gio.SettingsBindFlags.DEFAULT) + self.settings.bind("is-maximized", self, "maximized", Gio.SettingsBindFlags.DEFAULT) + self.settings.bind("is-fullscreen", self, "fullscreened", Gio.SettingsBindFlags.DEFAULT) + # self.scrolled_window.add_controller(file_drop) + # self.main_split.set_content(PackagesPage(self)) + if Config.DEVEL: + self.add_css_class("devel") + + # Connections + file_drop.connect("drop", self.on_file_drop) + file_drop.connect("enter", self.on_drop_enter) + file_drop.connect("leave", self.on_drop_leave) + event_controller.connect("key-pressed", self.key_handler) + # file_drop.connect("drop", self.drop_callback) + self.refresh_button.connect("clicked", self.refresh_handler) + + # self.activate_row(self.user_data_row) + # self.main_split.set_show_sidebar(self.settings.get_boolean("sidebar-shown")) + # GLib.idle_add(lambda *_: self.main_split.set_show_sidebar(False)) + # print(self.settings.get_boolean("sidebar-shown")) + # self.main_split.connect("notify::show-sidebar", self.save_sidebar_state) + + self.start_loading() + HostInfo.get_flatpaks(callback=self.end_loading) + # GLib.idle_add(lambda *_: self.main_split.set_show_sidebar(False)) diff --git a/src/meson.build b/src/meson.build index 8039d31..3b6a47a 100644 --- a/src/meson.build +++ b/src/meson.build @@ -1,18 +1,36 @@ pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) -moduledir = join_paths(pkgdatadir, 'flattool_gui') +moduledir = join_paths(pkgdatadir, 'Warehouse') gnome = import('gnome') blueprints = custom_target('blueprints', input: files( + 'gtk/app_row.blp', 'gtk/help-overlay.blp', - '../data/ui/window.blp', - '../data/ui/orphans.blp', - '../data/ui/filter.blp', - '../data/ui/remotes.blp', - '../data/ui/downgrade.blp', - '../data/ui/search_install.blp', - '../data/ui/snapshots.blp', - '../data/ui/properties.blp', + 'gtk/loading_status.blp', + 'gtk/installation_chooser.blp', + 'gtk/attempt_install_dialog.blp', + 'main_window/window.blp', + 'packages_page/packages_page.blp', + 'packages_page/filters_page.blp', + 'packages_page/uninstall_dialog.blp', + 'properties_page/properties_page.blp', + 'user_data_page/data_box.blp', + 'user_data_page/user_data_page.blp', + 'user_data_page/data_subpage.blp', + 'remotes_page/remotes_page.blp', + 'remotes_page/remote_row.blp', + 'remotes_page/add_remote_dialog.blp', + 'change_version_page/change_version_page.blp', + 'snapshot_page/snapshot_page.blp', + 'snapshot_page/snapshots_list_page.blp', + 'snapshot_page/snapshot_box.blp', + 'snapshot_page/new_snapshot_dialog.blp', + 'install_page/file_install_dialog.blp', + 'install_page/install_page.blp', + 'install_page/result_row.blp', + 'install_page/select_page.blp', + 'install_page/results_page.blp', + 'install_page/pending_page.blp', ), output: '.', command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'], @@ -54,31 +72,39 @@ configure_file( warehouse_sources = [ '__init__.py', 'main.py', - 'common.py', - 'window.py', - 'app_row_widget.py', + 'host_info.py', + 'gtk/error_toast.py', + 'gtk/sidebar_button.py', + 'gtk/loading_status.py', + 'gtk/installation_chooser.py', + 'gtk/app_row.py', + 'gtk/attempt_install_dialog.py', + 'main_window/window.py', + 'package_install_worker.py', + 'packages_page/uninstall_dialog.py', + 'packages_page/packages_page.py', + 'packages_page/filters_page.py', + 'properties_page/properties_page.py', + 'change_version_page/change_version_page.py', + 'change_version_page/change_version_worker.py', + 'user_data_page/data_box.py', + 'user_data_page/user_data_page.py', + 'user_data_page/data_subpage.py', + 'remotes_page/remotes_page.py', + 'remotes_page/remote_row.py', + 'remotes_page/add_remote_dialog.py', + 'snapshot_page/tar_worker.py', + 'snapshot_page/snapshot_page.py', + 'snapshot_page/snapshots_list_page.py', + 'snapshot_page/snapshot_box.py', + 'snapshot_page/new_snapshot_dialog.py', + 'install_page/file_install_dialog.py', + 'install_page/install_page.py', + 'install_page/result_row.py', + 'install_page/select_page.py', + 'install_page/results_page.py', + 'install_page/pending_page.py', '../data/style.css', - - 'properties_window.py', - '../data/ui/properties.blp', - - 'orphans_window.py', - '../data/ui/orphans.blp', - - 'remotes_window.py', - '../data/ui/remotes.blp', - - 'filter_window.py', - '../data/ui/filter.blp', - - 'downgrade_window.py', - '../data/ui/downgrade.blp', - - 'search_install_window.py', - '../data/ui/search_install.blp', - - 'snapshots_window.py', - '../data/ui/snapshots.blp', ] configure_file( diff --git a/src/orphans_window.py b/src/orphans_window.py deleted file mode 100644 index 89c407b..0000000 --- a/src/orphans_window.py +++ /dev/null @@ -1,353 +0,0 @@ -from gi.repository import Gtk, Adw, GLib, Gdk, Gio -from .common import myUtils -import subprocess -import os -import pathlib - - -@Gtk.Template(resource_path="/io/github/flattool/Warehouse/../data/ui/orphans.ui") -class OrphansWindow(Adw.Dialog): - __gtype_name__ = "OrphansWindow" - - list_of_data = Gtk.Template.Child() - install_button = Gtk.Template.Child() - trash_button = Gtk.Template.Child() - select_all_button = Gtk.Template.Child() - main_overlay = Gtk.Template.Child() - toast_overlay = Gtk.Template.Child() - main_stack = Gtk.Template.Child() - no_data = Gtk.Template.Child() - no_results = Gtk.Template.Child() - action_bar = Gtk.Template.Child() - search_button = Gtk.Template.Child() - search_bar = Gtk.Template.Child() - search_entry = Gtk.Template.Child() - oepn_folder_button = Gtk.Template.Child() - installing = Gtk.Template.Child() - main_box = Gtk.Template.Child() - installing_status = Gtk.Template.Child() - - window_title = _("Manage Leftover Data") - host_home = str(pathlib.Path.home()) - user_data_path = host_home + "/.var/app/" - should_select_all = False - selected_remote = "" - selected_remote_install_type = "" - is_result = False - is_installing = False - is_open = False - - def key_handler(self, controller, keyval, keycode, state): - if keyval == Gdk.KEY_Escape or ( - keyval == Gdk.KEY_w and state == Gdk.ModifierType.CONTROL_MASK - ): - self.close() - - def selection_handler(self, widget, dir_name): - if widget.get_active(): - self.selected_dirs.append(dir_name) - else: - self.selected_dirs.remove(dir_name) - - if len(self.selected_dirs) == 0: - self.set_title( - self.window_title - ) # Set the window title back to what it was when there are no selected dirs - else: - self.set_title( - ("{} selected").format(str(len(self.selected_dirs))) - ) # Set the window title to the amount of selected dirs - - if len(self.selected_dirs) == 0: - self.install_button.set_sensitive(False) - self.trash_button.set_sensitive(False) - else: - self.install_button.set_sensitive(True) - self.trash_button.set_sensitive(True) - - def select_all_handler(self, button): - for check in self.check_buttons: - check.set_active(button.get_active()) - - def install_callback(self, *_args): - self.is_installing = False - self.generate_list() - self.progress_bar.set_visible(False) - self.app_window.refresh_list_of_flatpaks(self) - self.set_can_close(True) # Make window able to close - self.search_button.set_sensitive(True) - if self.my_utils.install_success: - self.toast_overlay.add_toast(Adw.Toast.new(_("Installed successfully"))) - else: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not install some apps")) - ) - - def install_handler(self): - self.is_installing = True - self.main_stack.set_visible_child(self.installing) - self.search_button.set_sensitive(False) - self.set_title(self.window_title) - self.keep_checking = True - task = Gio.Task.new(None, None, self.install_callback) - task.run_in_thread( - lambda _task, _obj, _data, _cancellable, id_list=self.selected_dirs, remote=self.selected_remote, app_type=self.selected_remote_type, progress_bar=self.progress_bar, status_label=self.installing_status: self.my_utils.install_flatpak( - id_list, remote, app_type, progress_bar, status_label - ) - ) - - def install_button_handler(self, button): - def remote_select_handler(button, index): - if not button.get_active(): - return - self.selected_remote = self.host_remotes[index][0] - self.selected_remote_type = self.my_utils.get_install_type( - self.host_remotes[index][7] - ) - - def on_response(dialog, response_id, _function): - if response_id == "cancel": - return - self.install_handler() - self.progress_bar.set_visible(True) - self.action_bar.set_visible(False) - self.set_can_close(False) # Make window unable to close - - dialog = Adw.AlertDialog.new( - _("Attempt to Install?"), - _("Warehouse will attempt to install apps matching the selected data."), - ) - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Install")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.SUGGESTED) - - height = 65 * len(self.host_remotes) - max = 400 - if height > max: - height = max - remotes_scroll = Gtk.ScrolledWindow(vexpand=True, min_content_height=height) - remote_list = Gtk.ListBox(selection_mode="none", valign="start") - remotes_scroll.set_child(remote_list) - remote_list.add_css_class("boxed-list") - - total_added = 0 - remote_select_buttons = [] - for i in range(len(self.host_remotes)): - title = self.host_remotes[i][1] - name = self.host_remotes[i][0] - type_arr = self.host_remotes[i][7] - if "disabled" in type_arr: - continue - remote_row = Adw.ActionRow(title=title) - remote_select = Gtk.CheckButton() - remote_select_buttons.append(remote_select) - remote_select.connect("toggled", remote_select_handler, i) - remote_row.set_activatable_widget(remote_select) - - type = self.my_utils.get_install_type(type_arr) - if type == "user": - remote_row.set_subtitle(_("User wide")) - elif type == "system": - remote_row.set_subtitle(_("System wide")) - else: - remote_row.set_subtitle(_("Unknown install type")) - - if remote_row.get_title() == "-": - remote_row.set_title(self.host_remotes[i][0]) - - if total_added > 0: - remote_select.set_group(remote_select_buttons[0]) - - remote_row.add_prefix(remote_select) - remote_list.append(remote_row) - total_added += 1 - - remote_select_buttons[0].set_active(True) - - if total_added > 1: - dialog.set_extra_child(remotes_scroll) - - dialog.connect("response", on_response, dialog.choose_finish) - dialog.present(self) - - def trash_handler(self, button): - def on_response(dialog, response_id, _function): - if response_id == "cancel": - return - for i in range(len(self.selected_dirs)): - path = self.user_data_path + self.selected_dirs[i] - self.my_utils.trash_folder(path) - self.select_all_button.set_active(False) - self.generate_list() - - dialog = Adw.AlertDialog.new( - _("Trash folders?"), _("These folders will be sent to the trash.") - ) - dialog.connect("response", on_response, dialog.choose_finish) - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Continue")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.present(self) - - def open_button_handler(self, _widget, path=user_data_path): - try: - Gio.AppInfo.launch_default_for_uri(f"file://{path}", None) - except GLib.GError: - selt.toast_overlay.add_toast(Adw.Toast.new(_("Could not open folder"))) - - def size_callback(self, row_index): - row = self.list_of_data.get_row_at_index(row_index) - row.set_subtitle(f"~{self.data_rows[row_index][1]}") - - def size_thread(self, index, path): - size = self.my_utils.get_size_with_format(path) - self.data_rows[index].append(size) - - # Create the list of folders in the window - def generate_list(self): - self.data_rows = [] - self.check_buttons = [] - self.host_flatpaks = self.my_utils.get_host_flatpaks() - - if self.host_flatpaks == [["", ""]]: - self.app_window.toast_overlay.add_toast( - Adw.Toast.new(_("Could not manage data")) - ) - self.this_just_crashes_the_window_so_it_doesnt_open() - return - - self.list_of_data.remove_all() - self.selected_dirs = [] - self.set_title(self.window_title) - dir_list = os.listdir(self.user_data_path) - - # This is a list that only holds IDs of install flatpaks - id_list = [] - for i in range(len(self.host_flatpaks)): - try: - id_list.append(self.host_flatpaks[i][2]) - except: - print("Could not get data") - - for i in range(len(dir_list)): - dir_name = dir_list[i] - - # Skip item if it has a matching flatpak - if dir_name in id_list: - continue - - # Create row element - dir_row = Adw.ActionRow(title=dir_name) - self.data_rows.append([dir_row]) - path = self.user_data_path + dir_name - index = len(self.data_rows) - 1 - task = Gio.Task.new( - None, None, lambda *_, index=index: self.size_callback(index) - ) - task.run_in_thread( - lambda _task, _obj, _data, _cancellable, *_, index=index: self.size_thread( - index, path - ) - ) - - open_row_button = Gtk.Button( - icon_name="document-open-symbolic", - valign=Gtk.Align.CENTER, - tooltip_text=_("Open User Data Folder"), - ) - open_row_button.add_css_class("flat") - open_row_button.connect( - "clicked", self.open_button_handler, (self.user_data_path + dir_name) - ) - dir_row.add_suffix(open_row_button) - - select_button = Gtk.CheckButton(tooltip_text=_("Select")) - self.check_buttons.append(select_button) - select_button.add_css_class("selection-mode") - select_button.connect("toggled", self.selection_handler, dir_name) - dir_row.add_suffix(select_button) - dir_row.set_activatable_widget(select_button) - - # Add row to list - self.list_of_data.append(dir_row) - - if self.list_of_data.get_row_at_index(0) == None: - self.main_stack.set_visible_child(self.no_data) - self.action_bar.set_visible(False) - else: - self.main_stack.set_visible_child(self.main_box) - self.action_bar.set_visible(True) - - def filter_func(self, row): - if self.search_entry.get_text().lower() in row.get_title().lower(): - self.is_result = True - return True - - def on_invalidate(self, row): - if self.is_installing: - return - if self.list_of_data.get_row_at_index(0) == None: - self.main_stack.set_visible_child(self.no_data) - self.action_bar.set_visible(False) - else: - self.main_stack.set_visible_child(self.main_box) - self.action_bar.set_visible(True) - - self.is_result = False - self.list_of_data.invalidate_filter() - if self.is_result == False: - self.main_stack.set_visible_child(self.no_results) - self.action_bar.set_visible(False) - - def on_change(self, prop, prop2): - if self.is_installing: - return - if self.search_bar.get_search_mode() == False: - if self.list_of_data.get_row_at_index(0) == None: - self.main_stack.set_visible_child(self.no_data) - self.action_bar.set_visible(False) - else: - self.main_stack.set_visible_child(self.main_box) - self.action_bar.set_visible(True) - - def __init__(self, main_window, **kwargs): - super().__init__(**kwargs) - self.my_utils = myUtils( - self - ) # Access common utils and set the window to this window - self.host_remotes = self.my_utils.get_host_remotes() - self.host_flatpaks = self.my_utils.get_host_flatpaks() - - self.progress_bar = Gtk.ProgressBar(visible=False) - self.progress_bar.add_css_class("osd") - self.app_window = main_window - - self.generate_list() - - event_controller = Gtk.EventControllerKey() - event_controller.connect("key-pressed", self.key_handler) - self.add_controller(event_controller) - - self.install_button.connect("clicked", self.install_button_handler) - if self.host_remotes[0][0] == "": - self.install_button.set_visible(False) - self.trash_button.connect("clicked", self.trash_handler) - self.select_all_button.connect("toggled", self.select_all_handler) - self.main_overlay.add_overlay(self.progress_bar) - - self.list_of_data.set_filter_func(self.filter_func) - self.search_entry.connect("search-changed", self.on_invalidate) - self.search_bar.connect("notify", self.on_change) - self.search_bar.connect_entry(self.search_entry) - self.oepn_folder_button.connect("clicked", self.open_button_handler) - - def set_is_open_false(*args): - self.__class__.is_open = False - self.connect("closed", set_is_open_false) - if self.__class__.is_open: - return - else: - self.present(main_window) - self.__class__.is_open = True diff --git a/src/package_install_worker.py b/src/package_install_worker.py new file mode 100644 index 0000000..31cdefe --- /dev/null +++ b/src/package_install_worker.py @@ -0,0 +1,140 @@ +from gi.repository import Gio, GLib +from .host_info import HostInfo +import subprocess, re + +class PackageInstallWorker: + """ Expect Package Installation Request Data to be Formatted as Such + [ + { + "remote": "" or "local_file", + "installation": "", + "package_names": ["", "", ...], + "extra_flags": ["", "", ...], + }, + { + ... + }, + ] + """ + + groups = None + process = None + callback = None + error_callback = None + loading_status = None + total_groups = 0 + cancelled = False + + @classmethod + def update_status(this, index, package_ratio, complete, total): + group_ratio = (package_ratio + complete) / (total or 1) + final_ratio = (group_ratio + index) / (this.total_groups or 1) + + print(f"gr: {(package_ratio + complete) / (total or 1):.2f}, fr: {((package_ratio + complete) / (total or 1) + index) / (this.total_groups or 1):.2f}") + print("i:", index, ", g:", this.total_groups, ", r:", package_ratio, ", c:", complete, ", t:", total) + print("=======================================") + + if not this.loading_status is None: + GLib.idle_add(lambda *_: this.loading_status.progress_bar.set_fraction(final_ratio)) + + @classmethod + def install_thread(this): + try: + errors = [] + for index, group in enumerate(this.groups): + if this.cancelled: + return + + real_installation = "" + installation = group['installation'] + if installation == "user" or installation == "system": + real_installation = f"--{installation}" + else: + real_installation = f"--installation={installation}" + + cmd = ['flatpak-spawn', '--host', 'flatpak', 'install', '-y'] + + # Handle local file installs. They don't have a remote specified + if group['remote'] != "local_file": + cmd.append(group['remote']) + + cmd += [real_installation] + group['package_names'] + group['extra_flags'] + this.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + percent_pattern = r'\d{1,3}%' + amount_pattern = r'(\d+)/(\d+)' + for line in this.process.stdout: + line = line.strip() + percent_match = re.search(percent_pattern, line) + if percent_match: + ratio = int(percent_match.group()[0:-1]) / 100.0 + amount_match = re.search(amount_pattern, line) + if amount_match: + amount = amount_match.group().split('/') + complete = int(amount[0]) - 1 + total = int(amount[1]) + this.update_status(index, ratio, complete, total) + else: + this.update_status(index, ratio, 0, 1) + + this.process.wait(timeout=10) + if error := this.process.communicate()[1].strip(): + errors.append(error) + + if len(errors) > 0: + this.on_error(_("Errors occurred during installation"), "\n".join(errors)) + + except subprocess.TimeoutExpired as te: + this.process.terminate() + this.on_error(_("Error occurred during installation"), _("Failed to exit cleanly")) + + except Exception as e: + this.process.terminate() + this.on_error(_("Error occurred during installation"), str(e)) + + @classmethod + def cancel(this): + if this.process is None: + return + + try: + this.cancelled = True + this.process.terminate() + this.process.wait(timeout=10) + except Exception as e: + this.on_error(_("Could not cancel installation"), str(e)) + + @classmethod + def on_done(this, *args): + this.process = None + this.cancelled = False + HostInfo.main_window.remove_refresh_lockout("installing packages") + if not this.loading_status is None: + this.loading_status.progress_bar.set_fraction(0.0) + + if not this.callback is None: + this.callback() + + @classmethod + def on_error(this, user_facing_label, error_message): + if not this.error_callback is None: + this.error_callback(user_facing_label, error_message) + + @classmethod + def install(this, groups, loading_status=None, callback=None, error_callback=None): + if not this.process is None: + this.on_error(_("Could not install packages"), _("Packages are currently being installed.")) + return False + + this.callback = callback + this.groups = groups + this.total_groups = len(groups) + this.loading_status = loading_status + this.error_callback = error_callback + + if this.total_groups < 1: + this.on_error(_("Could not install packages"), _("No packages were requested to be installed.")) + return False + + HostInfo.main_window.add_refresh_lockout("installing packages") + Gio.Task.new(None, None, this.on_done).run_in_thread(lambda *_: this.install_thread()) + return True diff --git a/src/packages_page/filters_page.blp b/src/packages_page/filters_page.blp new file mode 100644 index 0000000..c453265 --- /dev/null +++ b/src/packages_page/filters_page.blp @@ -0,0 +1,118 @@ +using Gtk 4.0; +using Adw 1; + +template $FiltersPage : Adw.NavigationPage { + title: _("Filter Packages"); + Adw.ToolbarView { + [top] + Adw.HeaderBar {} + ScrolledWindow { + Adw.Clamp { + Box { + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + spacing: 24; + orientation: vertical; + halign: fill; + Adw.PreferencesGroup { + title: _("Filter by Package Type"); + description: _("Show packages of these types"); + Adw.ActionRow application_row { + title: _("Applications"); + subtitle: _("Packages that can be opened"); + CheckButton app_check {} + activatable-widget: app_check; + } + Adw.ActionRow runtime_row { + title: _("Runtimes"); + subtitle: _("Packages that applications depend on"); + CheckButton runtime_check {} + activatable-widget: runtime_check; + } + } + + Adw.PreferencesGroup remotes_group { + title: _("Filter by Remotes"); + description: _("Show packages from selected remotes"); + header-suffix: + Switch all_remotes_switch { + valign: center; + } + ; + Adw.ActionRow { + visible: bind all_remotes_switch.active inverted; + [child] + Box { + spacing: 3; + orientation: vertical; + Label { + margin-top: 7; + label: _("Showing packages from all remotes"); + wrap: true; + halign: center; + styles ["heading"] + } + Label { + label: _("Enable to show packages from selected remotes"); + margin-start: 16; + margin-end: 16; + margin-bottom: 8; + justify: center; + halign: center; + wrap: true; + } + } + } + } + + Adw.PreferencesGroup runtimes_group { + title: _("Filter by Runtimes"); + description: _("Show apps using selected runtimes"); + header-suffix: + Switch all_runtimes_switch { + valign: center; + } + ; + Adw.ActionRow { + visible: bind all_runtimes_switch.active inverted; + [child] + Box { + spacing: 3; + orientation: vertical; + Label { + margin-top: 7; + label: _("Showing apps using any runtime"); + wrap: true; + halign: center; + styles ["heading"] + } + Label { + label: _("Enable to show apps using selected runtimes"); + margin-start: 16; + margin-end: 16; + margin-bottom: 8; + justify: center; + halign: center; + wrap: true; + } + } + } + } + } + } + } + [bottom] + ActionBar action_bar { + [center] + Button reset_button { + sensitive: bind action_bar.revealed; + margin-top: 3; + margin-bottom: 3; + label: _("Reset Filters"); + styles ["pill"] + } + } + } +} \ No newline at end of file diff --git a/src/packages_page/filters_page.py b/src/packages_page/filters_page.py new file mode 100644 index 0000000..9d69b8f --- /dev/null +++ b/src/packages_page/filters_page.py @@ -0,0 +1,196 @@ +from gi.repository import Adw, Gtk, Gio +from .host_info import HostInfo + +class FilterRow(Adw.ActionRow): + __gtype_name__ = 'FilterRow' + def __init__(self, item=None, installation=None, **kwargs): + super().__init__(**kwargs) + self.item = item + self.installation = installation + self.check_button = Gtk.CheckButton() + self.add_suffix(self.check_button) + self.set_activatable_widget(self.check_button) + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/packages_page/filters_page.ui") +class FiltersPage(Adw.NavigationPage): + __gtype_name__ = 'FiltersPage' + gtc = Gtk.Template.Child + app_check = gtc() + runtime_check = gtc() + remotes_group = gtc() + all_remotes_switch = gtc() + runtimes_group = gtc() + all_runtimes_switch = gtc() + action_bar = gtc() + reset_button = gtc() + + remote_rows = [] + runtime_rows = [] + + def reset_filters(self): + self.settings.reset("show-apps") + self.settings.reset("show-runtimes") + self.settings.reset("remotes-list") + self.settings.reset("runtimes-list") + self.generate_filters() + self.packages_page.apply_filters() + + def is_defaulted(self): + default = True + if not self.app_check.get_active(): + default = False + if self.runtime_check.get_active(): + default = False + if self.all_remotes_switch.get_active(): + default = False + if self.all_runtimes_switch.get_active(): + default = False + self.action_bar.set_revealed(not default) + + def update_gsettings(self): + self.is_defaulted() + if not self.is_settings_settable: + return + self.settings.set_boolean("show-apps", self.show_apps) + self.settings.set_boolean("show-runtimes", self.show_runtimes) + self.settings.set_string("remotes-list", self.remotes_string) + self.settings.set_string("runtimes-list", self.runtimes_string) + self.packages_page.apply_filters() + + def app_check_handler(self, *args): + self.show_apps = self.app_check.get_active() + self.update_gsettings() + + def runtime_check_handler(self, *args): + self.show_runtimes = self.runtime_check.get_active() + self.update_gsettings() + + def all_remotes_handler(self, switch, state): + self.remotes_string = "" + if not state: + self.remotes_string = "all" + + for row in self.remote_rows: + row.set_visible(state) + if state and row.check_button.get_active(): + self.remotes_string += f"{row.item.name}<>{row.installation};" + elif state: + self.remotes_string.replace(f"{row.item.name}<>{row.installation};", "") + + self.update_gsettings() + + def all_runtimes_handler(self, switch, state): + self.runtimes_string = "" + if not state: + self.runtimes_string = "all" + + for row in self.runtime_rows: + row.set_visible(state) + if state and row.check_button.get_active(): + self.runtimes_string += f"{row.item};" + elif state: + self.runtimes_string.replace(f"{row.item};", "") + + self.update_gsettings() + + def remote_row_check_handler(self, row): + if row.check_button.get_active(): + self.remotes_string += f"{row.item.name}<>{row.installation};" + else: + self.remotes_string = self.remotes_string.replace(f"{row.item.name}<>{row.installation};", "") + self.update_gsettings() + + def runtime_row_check_handler(self, row): + if row.check_button.get_active(): + self.runtimes_string += f"{row.item};" + else: + self.runtimes_string = self.runtimes_string.replace(f"{row.item};", "") + self.update_gsettings() + + def generate_remote_filters(self): + for row in self.remote_rows: + self.remotes_group.remove(row) + + self.remote_rows.clear() + for installation, remotes in HostInfo.remotes.items(): + for remote in remotes: + if remote.disabled: + continue + + row = FilterRow(remote, installation) + row.set_title(remote.title) + row.set_subtitle(_("Installation: {}").format(installation)) + row.check_button.set_active(f"{remote.name}<>{installation}" in self.remotes_string) + row.check_button.connect("toggled", lambda *_, row=row: self.remote_row_check_handler(row)) + row.set_visible(self.all_remotes_switch.get_active()) + self.remote_rows.append(row) + self.remotes_group.add(row) + + self.remotes_group.set_visible(len(self.remote_rows) > 1) + self.all_remotes_switch.set_active("all" != self.remotes_string) + + def generate_runtime_filters(self): + for row in self.runtime_rows: + self.runtimes_group.remove(row) + self.runtime_rows.clear() + if len(HostInfo.dependant_runtime_refs) < 2: + self.runtimes_group.set_visible(False) + if self.runtimes_string != "all": + self.runtimes_string = "all" + self.settings.set_string("runtimes-list", self.runtimes_string) + self.packages_page.apply_filters() + + return + + for j, ref in enumerate(HostInfo.dependant_runtime_refs): + row = FilterRow(ref) + row.set_title(ref) + row.check_button.set_active(ref in self.runtimes_string) + row.check_button.connect("toggled", lambda *_, row=row: self.runtime_row_check_handler(row)) + row.set_visible(self.all_runtimes_switch.get_active()) + self.runtime_rows.append(row) + self.runtimes_group.add(row) + + self.runtimes_group.set_visible(len(self.runtime_rows) > 1) + self.all_runtimes_switch.set_active("all" != self.runtimes_string) + + def generate_filters(self): + self.is_settings_settable = False + self.show_apps = self.settings.get_boolean("show-apps") + self.show_runtimes = self.settings.get_boolean("show-runtimes") + self.remotes_string = self.settings.get_string("remotes-list") + self.runtimes_string = self.settings.get_string("runtimes-list") + + self.app_check.set_active(self.show_apps) + self.runtime_check.set_active(self.show_runtimes) + + self.generate_remote_filters() + self.generate_runtime_filters() + + self.is_settings_settable = True + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Extra Objects Creation + self.packages_page = None # To be set in packages page + self.main_window = HostInfo.main_window + self.settings = Gio.Settings.new("io.github.flattool.Warehouse.filter") + self.is_settings_settable = False + self.show_apps = self.settings.get_boolean("show-apps") + self.show_runtimes = self.settings.get_boolean("show-runtimes") + self.remotes_string = self.settings.get_string("remotes-list") + self.runtimes_string = self.settings.get_string("runtimes-list") + + # Apply + if "," in self.runtimes_string: + # Convert Warehouse 1.X runtimes filter string from , to ; for item seperationg + self.runtimes_string = self.runtimes_string.replace(",", ";") + self.settings.set_string("runtimes-list", self.runtimes_string) + + # Connections + self.app_check.connect("toggled", self.app_check_handler) + self.runtime_check.connect("toggled", self.runtime_check_handler) + self.all_remotes_switch.connect("state-set", self.all_remotes_handler) + self.all_runtimes_switch.connect("state-set", self.all_runtimes_handler) + self.reset_button.connect("clicked", lambda *_: self.reset_filters()) diff --git a/src/packages_page/packages_page.blp b/src/packages_page/packages_page.blp new file mode 100644 index 0000000..1513fdc --- /dev/null +++ b/src/packages_page/packages_page.blp @@ -0,0 +1,185 @@ +using Gtk 4.0; +using Adw 1; + +template $PackagesPage : Adw.BreakpointBin { + width-request: 1; + height-request: 1; + + Adw.Breakpoint packages_bpt { + condition ("max-width: 600") + + setters { + packages_split.collapsed: true; + packages_split.show-content: false; + content_stack.transition-duration: 9999999; + reset_filters_button.visible: true; + } + } + + Adw.NavigationPage { + title: _("Packages"); + Stack stack { + Adw.ToolbarView loading_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + Adw.ToolbarView uninstalling_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + Adw.ToolbarView reinstalling_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + Adw.ToolbarView changing_version_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + Adw.NavigationSplitView packages_split { + sidebar-width-fraction: 0.5; + max-sidebar-width: 999999999; + sidebar: + Adw.NavigationPage packages_navpage { + title: _("Packages"); + Adw.ToastOverlay packages_toast_overlay { + Adw.ToolbarView packages_tbv { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + [start] + ToggleButton search_button { + icon-name: "loupe-large-symbolic"; + tooltip-text: _("Search Packages"); + } + [end] + ToggleButton filter_button { + icon-name: "funnel-symbolic"; + tooltip-text: _("Filter Packages"); + } + [end] + ToggleButton select_button { + icon-name: "selection-mode-symbolic"; + tooltip-text: _("Select Packages"); + } + } + [top] + SearchBar search_bar { + search-mode-enabled: bind search_button.active bidirectional; + SearchEntry search_entry { + hexpand: true; + placeholder-text: _("Search Packages"); + } + } + Stack status_stack { + ScrolledWindow scrolled_window { + ListBox packages_list_box { + styles ["navigation-sidebar"] + } + } + Adw.StatusPage no_filter_results { + title: _("No Packages Match Filters"); + description: _("No installed package matches all of the currently applied filters"); + icon-name: "funnel-symbolic"; + Button reset_filters_button { + label: _("Reset Filters"); + halign: center; + visible: false; + styles ["pill"] + } + } + Adw.StatusPage no_packages { + title: _("No Packages Found"); + description: _("Warehouse cannot see the list of installed packages or your system has no packages installed"); + icon-name: "error-symbolic"; + } + Adw.StatusPage no_results { + title: _("No Results Found"); + description: _("Try a different search"); + icon-name: "system-search-symbolic"; + } + } + [bottom] + Revealer { + reveal-child: bind select_button.active; + transition-type: slide_up; + [center] + Box bottom_bar { + styles ["toolbar"] + hexpand: true; + homogeneous: true; + Button select_all_button { + styles ["raised"] + Adw.ButtonContent { + icon-name: "selection-mode-symbolic"; + label: _("Select All"); + can-shrink: true; + } + } + MenuButton copy_button { + styles ["raised"] + Adw.ButtonContent { + icon-name: "edit-copy-symbolic"; + label: _("Copy"); + can-shrink: true; + } + popover: copy_pop; + } + Button uninstall_button { + styles ["raised"] + Adw.ButtonContent { + icon-name: "user-trash-symbolic"; + label: _("Uninstall"); + can-shrink: true; + } + } + } + } + } + } + } + ; + content: + Adw.NavigationPage { + title: "Content Stack"; + Stack content_stack { + transition-type: slide_left_right; + $PropertiesPage properties_page {} + $FiltersPage filters_page {} + } + } + ; + } + } + } +} + +Popover copy_pop { + styles ["menu"] + ListBox copy_menu { + Label copy_names { + label: _("Copy Names"); + halign: start; + } + Label copy_ids { + label: _("Copy IDs"); + halign: start; + } + Label copy_refs { + label: _("Copy Refs"); + halign: start; + } + } +} diff --git a/src/packages_page/packages_page.py b/src/packages_page/packages_page.py new file mode 100644 index 0000000..6682c7c --- /dev/null +++ b/src/packages_page/packages_page.py @@ -0,0 +1,368 @@ +from gi.repository import Adw, Gtk, GLib, Gio, Gdk +from .host_info import HostInfo +from .app_row import AppRow +from .error_toast import ErrorToast +from .properties_page import PropertiesPage +from .filters_page import FiltersPage +from .sidebar_button import SidebarButton +from .uninstall_dialog import UninstallDialog +from .loading_status import LoadingStatus +from .package_install_worker import PackageInstallWorker +from .change_version_worker import ChangeVersionWorker +import subprocess, os + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/packages_page/packages_page.ui") +class PackagesPage(Adw.BreakpointBin): + __gtype_name__ = 'PackagesPage' + gtc = Gtk.Template.Child + packages_bpt = gtc() + packages_toast_overlay = gtc() + stack = gtc() + status_stack = gtc() + scrolled_window = gtc() + loading_view = gtc() + uninstalling_view = gtc() + reinstalling_view = gtc() + changing_version_view = gtc() + no_filter_results = gtc() + reset_filters_button = gtc() + no_packages = gtc() + no_results = gtc() + filter_button = gtc() + search_button = gtc() + search_bar = gtc() + search_entry = gtc() + packages_split = gtc() + packages_list_box = gtc() + select_button = gtc() + packages_navpage = gtc() + select_all_button = gtc() + content_stack = gtc() + copy_button = gtc() + copy_pop = gtc() + copy_menu = gtc() + copy_names = gtc() + copy_ids = gtc() + copy_refs = gtc() + uninstall_button = gtc() + properties_page = gtc() + filters_page = gtc() + + # Referred to in the main window + # It is used to determine if a new page should be made or not + # This must be set to the created object from within the class's __init__ method + instance = None + page_name = "packages" + + def set_status(self, to_set): + + if to_set is self.scrolled_window: + self.properties_page.stack.set_visible_child(self.properties_page.nav_view) + self.select_button.set_sensitive(True) + self.filter_button.set_sensitive(True) + self.filters_page.set_sensitive(True) + + self.search_button.set_sensitive(True) + self.search_entry.set_editable(True) + else: + self.select_button.set_sensitive(False) + + if to_set is self.no_packages: + self.properties_page.stack.set_visible_child(self.properties_page.error_tbv) + self.filter_button.set_sensitive(False) + self.filter_button.set_active(False) + + if to_set is self.no_filter_results: + self.properties_page.stack.set_visible_child(self.properties_page.error_tbv) + self.filter_button.set_sensitive(True) + self.filters_page.set_sensitive(True) + if not self.packages_split.get_collapsed(): + self.filter_button.set_active(True) + + if to_set is self.no_results: + self.filters_page.set_sensitive(False) + + if to_set is self.loading_packages: + self.stack.set_visible_child(self.loading_view) + elif to_set is self.uninstalling: + self.stack.set_visible_child(self.uninstalling_view) + elif to_set is self.reinstalling: + self.stack.set_visible_child(self.reinstalling_view) + elif to_set is self.changing_version: + self.stack.set_visible_child(self.changing_version_view) + else: + self.stack.set_visible_child(self.packages_split) + self.status_stack.set_visible_child(to_set) + + def apply_filters(self): + i = 0 + show_apps = self.filter_settings.get_boolean("show-apps") + show_runtimes = self.filter_settings.get_boolean("show-runtimes") + remotes_list = self.filter_settings.get_string("remotes-list") + runtimes_list = self.filter_settings.get_string("runtimes-list") + total_visible = 0 + while row := self.packages_list_box.get_row_at_index(i): + i += 1 + visible = True + if row.package.is_runtime and not show_runtimes: + visible = False + if (not row.package.is_runtime) and (not show_apps): + visible = False + if remotes_list != "all" and not f"{row.package.info['origin']}<>{row.package.info['installation']}" in remotes_list: + visible = False + if runtimes_list != "all" and (row.package.is_runtime or row.package.dependant_runtime and not row.package.dependant_runtime.info["ref"] in runtimes_list): + visible = False + + row.set_visible(visible) + if visible: + total_visible += 1 + else: + row.check_button.set_active(False) + + if total_visible == 0: + self.set_status(self.no_filter_results) + else: + GLib.idle_add(lambda *_: self.set_status(self.scrolled_window)) + if self.current_row_for_properties and not self.current_row_for_properties.get_visible(): + self.select_first_visible_row() + + def select_first_visible_row(self): + first_visible_row = None + i = 0 + while row := self.packages_list_box.get_row_at_index(i): + i += 1 + if row.get_visible(): + first_visible_row = row + self.current_row_for_properties = row + break + + if not first_visible_row is None: + self.packages_list_box.select_row(first_visible_row) + self.properties_page.set_properties(first_visible_row.package) + + def row_select_handler(self, row): + if row.check_button.get_active(): + self.selected_rows.append(row) + else: + self.selected_rows.remove(row) + + if (total := len(self.selected_rows)) > 0: + self.packages_navpage.set_title(_("{} Selected").format(total)) + self.copy_button.set_sensitive(True) + self.uninstall_button.set_sensitive(True) + else: + self.packages_navpage.set_title(_("Packages")) + self.copy_button.set_sensitive(False) + self.uninstall_button.set_sensitive(False) + + def select_all_handler(self, *args): + i = 0 + while row := self.packages_list_box.get_row_at_index(i): + i += 1 + row.check_button.set_active(row.get_visible()) + + def row_rclick_handler(self, row): + self.select_button.set_active(True) + GLib.idle_add(lambda *_, button=row.check_button: button.set_active(not button.get_active())) + + def generate_list(self, *args): + self.properties_page.nav_view.pop_to_page(self.properties_page.inner_nav_page) + self.packages_list_box.remove_all() + self.selected_rows.clear() + GLib.idle_add(lambda *_: self.filters_page.generate_filters()) + self.copy_button.set_sensitive(False) + self.uninstall_button.set_sensitive(False) + if len(HostInfo.flatpaks) == 0: + self.set_status(self.no_packages) + return + + for package in HostInfo.flatpaks: + row = AppRow(package, self.row_rclick_handler) + package.app_row = row + row.masked_status_icon.set_visible(package.is_masked) + row.pinned_status_icon.set_visible(package.is_pinned) + row.eol_package_package_status_icon.set_visible(package.is_eol) + row.check_button.set_visible(self.select_button.get_active()) + row.check_button.connect("toggled", lambda *_, row=row: self.row_select_handler(row)) + try: + if not package.is_runtime: + row.eol_runtime_status_icon.set_visible(package.dependant_runtime.is_eol) + except Exception as e: + self.packages_toast_overlay.add_toast(ErrorToast(_("Error getting Flatpak '{}'").format(package.info["name"]), str(e)).toast) + + self.packages_list_box.append(row) + + self.apply_filters() + self.select_first_visible_row() + + self.scrolled_window.set_vadjustment(Gtk.Adjustment.new(0,0,0,0,0,0)) # Scroll list to top + + def row_activate_handler(self, list_box, row): + self.properties_page.set_properties(row.package) + self.properties_page.nav_view.pop() + self.packages_split.set_show_content(True) + self.filter_button.set_active(False) + self.current_row_for_properties = row + + def filter_func(self, row): + search_text = self.search_entry.get_text().lower() + title = row.get_title().lower() + subtitle = row.get_subtitle().lower() + if row.get_visible() and (search_text in title or search_text in subtitle): + self.is_result = True + return True + + def set_selection_mode(self, is_enabled): + i = 0 + while row := self.packages_list_box.get_row_at_index(i): + i += 1 + GLib.idle_add(row.check_button.set_active, False) + GLib.idle_add(row.check_button.set_visible, is_enabled) + + def selection_copy(self, box, row): + self.copy_pop.popdown() + info = "" + feedback = "" + match row.get_child(): + case self.copy_names: + info = "name" + feedback = _("Names") + case self.copy_ids: + info = "id" + feedback = _("IDs") + case self.copy_refs: + info = "ref" + feedback = _("Refs") + + to_copy = [] + for row in self.selected_rows: + to_copy.append(row.package.info[info]) + to_copy += ['\n'] + try: + HostInfo.clipboard.set("".join(to_copy[:-1])) + self.packages_toast_overlay.add_toast(Adw.Toast(title=_("Copied {}").format(feedback))) + except Exception as e: + self.packages_toast_overlay.add_toast(ErrorToast(_("Could not copy {}").format(feedback), str(e)).toast) + + def selection_uninstall(self, *args): + if len(self.selected_rows) < 1 or not self.uninstall_button.get_sensitive(): + return + + def on_response(should_trash): + GLib.idle_add(lambda *_: self.set_status(self.uninstalling)) + error = [None] + def thread(*args): + HostInfo.main_window.add_refresh_lockout("batch uninstalling packages") + cmd = ['flatpak-spawn', '--host', 'flatpak', 'uninstall', '-y'] + to_trash = [] + for row in self.selected_rows: + cmd.append(row.package.info["ref"]) + if should_trash and os.path.exists(row.package.data_path): + to_trash.append(row.package.data_path) + + try: + subprocess.run(cmd, check=True, capture_output=True) + if should_trash and len(to_trash) > 0: + subprocess.run(['gio', 'trash'] + to_trash, check=True, capture_output=True) + except subprocess.CalledProcessError as cpe: + error[0] = cpe + except Exception as e: + error[0] = e + + def callback(*args): + self.main_window.refresh_handler() + HostInfo.main_window.remove_refresh_lockout("batch uninstalling packages") + if err := error[0]: + details = err.stderr if type(err) == subprocess.CalledProcessError else str(err) + GLib.idle_add(lambda *args: self.packages_toast_overlay.add_toast(ErrorToast(_("Could not uninstall packages"), details).toast)) + else: + GLib.idle_add(lambda *args: self.packages_toast_overlay.add_toast(Adw.Toast(title=_("Uninstalled Packages")))) + + Gio.Task.new(None, None, callback).run_in_thread(thread) + + dialog = UninstallDialog(on_response, True) + dialog.present(self.main_window) + + def start_loading(self): + self.packages_navpage.set_title(_("Packages")) + self.select_button.set_active(False) + self.set_status(self.loading_packages) + + def end_loading(self): + GLib.idle_add(lambda *_: self.generate_list()) + + def select_button_handler(self, button): + self.set_selection_mode(button.get_active()) + + def filter_button_handler(self, button): + if button.get_active(): + self.content_stack.set_visible_child(self.filters_page) + self.packages_split.set_show_content(True) + else: + self.content_stack.set_visible_child(self.properties_page) + self.packages_split.set_show_content(False) + + def filter_page_handler(self, *args): + if self.packages_split.get_collapsed() and not self.packages_split.get_show_content(): + self.filter_button.set_active(False) + + def on_invalidate(self, row): + current_status = self.status_stack.get_visible_child() + if not current_status is self.no_results: + self.prev_status = current_status + + self.is_result = False + self.packages_list_box.invalidate_filter() + if self.is_result: + self.set_status(self.prev_status) + else: + self.set_status(self.no_results) + + def sort_func(self, row1, row2): + return row1.package.info["name"].lower() > row2.package.info["name"].lower() + + def on_escape_handler(self): + if self.select_button.get_active(): + self.select_button.set_active(False) + elif self.filter_button.get_active(): + self.filter_button.set_active(False) + + def __init__(self, main_window, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.main_window = main_window + self.loading_packages = LoadingStatus(_("Loading Packages"), _("This should only take a moment")) + self.uninstalling = LoadingStatus(_("Uninstalling Packages"), _("This should only take a moment")) + self.uninstalling_view.set_content(self.uninstalling) + self.reinstalling = LoadingStatus(_("Reinstalling Package"), _("This could take a while"), True, PackageInstallWorker.cancel) + self.reinstalling_view.set_content(self.reinstalling) + self.changing_version = LoadingStatus(_("Changing Version"), _("This could take a while"), True, ChangeVersionWorker.cancel) + self.changing_version_view.set_content(self.changing_version) + self.filter_settings = Gio.Settings.new("io.github.flattool.Warehouse.filter") + self.is_result = False + self.prev_status = None + self.selected_rows = [] + self.current_row_for_properties = None + self.on_backspace_handler = self.selection_uninstall + + # Apply + self.loading_view.set_content(self.loading_packages) + self.packages_list_box.set_filter_func(self.filter_func) + self.packages_list_box.set_sort_func(self.sort_func) + self.properties_page.packages_page = self + self.filters_page.packages_page = self + self.__class__.instance = self + + # Connections + self.search_entry.connect("search-changed", self.on_invalidate) + self.search_bar.set_key_capture_widget(main_window) + self.packages_list_box.connect("row-activated", self.row_activate_handler) + self.select_button.connect("toggled", self.select_button_handler) + self.filter_button.connect("toggled", self.filter_button_handler) + self.reset_filters_button.connect("clicked", lambda *_: self.filters_page.reset_filters()) + self.packages_split.connect("notify::show-content", self.filter_page_handler) + self.packages_bpt.connect("apply", self.filter_page_handler) + self.select_all_button.connect("clicked", self.select_all_handler) + self.copy_menu.connect("row-activated", self.selection_copy) + self.uninstall_button.connect("clicked", self.selection_uninstall) diff --git a/src/packages_page/uninstall_dialog.blp b/src/packages_page/uninstall_dialog.blp new file mode 100644 index 0000000..5ccc0ce --- /dev/null +++ b/src/packages_page/uninstall_dialog.blp @@ -0,0 +1,27 @@ +using Gtk 4.0; +using Adw 1; + +template $UninstallDialog : Adw.AlertDialog { + extra-child: + Adw.PreferencesGroup group { + Adw.ActionRow { + title: _("Keep"); + subtitle: _("Allows restoring app settings and content"); + activatable-widget: keep; + [prefix] + CheckButton keep { + active: true; + } + } + Adw.ActionRow { + title: _("Trash"); + subtitle: _("Send data to the trash"); + activatable-widget: trash; + [prefix] + CheckButton trash { + group: keep; + } + } + } + ; +} \ No newline at end of file diff --git a/src/packages_page/uninstall_dialog.py b/src/packages_page/uninstall_dialog.py new file mode 100644 index 0000000..535d35f --- /dev/null +++ b/src/packages_page/uninstall_dialog.py @@ -0,0 +1,42 @@ +from gi.repository import Adw, Gtk, GLib + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/packages_page/uninstall_dialog.ui") +class UninstallDialog(Adw.AlertDialog): + __gtype_name__ = "UninstallDialog" + gtc = Gtk.Template.Child + + group = gtc() + trash = gtc() + is_open = False + + def on_response(self, dialog, response): + self.__class__.is_open = False + if response != "continue": + return + + self.continue_callback(self.trash.get_active()) + + def present(self, *args, **kwargs): + if self.__class__.is_open: + return + + self.__class__.is_open = True + super().present(*args, **kwargs) + + def __init__(self, continue_callback, show_trash_option, package_name=None, **kwargs): + super().__init__(**kwargs) + + if package_name: + self.set_heading(GLib.markup_escape_text(_("Uninstall {}?").format(package_name))) + self.set_body(GLib.markup_escape_text(_("It will not be possible to use {} after removal").format(package_name))) + else: + self.set_heading(GLib.markup_escape_text(_("Uninstall Packages?"))) + self.set_body(GLib.markup_escape_text(_("It will not be possible to use these packages after removal"))) + + self.continue_callback = continue_callback + self.add_response("cancel", _("Cancel")) + self.add_response("continue", _("Uninstall")) + self.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) + self.connect("response", self.on_response) + self.group.set_title(GLib.markup_escape_text(_("App Settings & Content"))) + self.group.set_visible(show_trash_option) diff --git a/src/properties_page/properties_page.blp b/src/properties_page/properties_page.blp new file mode 100644 index 0000000..f800904 --- /dev/null +++ b/src/properties_page/properties_page.blp @@ -0,0 +1,328 @@ +using Gtk 4.0; +using Adw 1; + +template $PropertiesPage : Adw.NavigationPage { + title: "Outer Page"; + Stack stack { + Adw.ToolbarView loading_tbv { + [top] + Adw.HeaderBar { + show-title: false; + } + } + Adw.ToolbarView error_tbv { + [top] + Adw.HeaderBar { + show-title: false; + } + Adw.StatusPage { + title: _("Properties Page Unavailable"); + description: _("Cannot show the properties page at this time"); + icon-name: "error-symbolic"; + } + } + Adw.NavigationView nav_view { + Adw.NavigationPage inner_nav_page { + title: "Inner Page"; + Adw.ToastOverlay toast_overlay { + Adw.ToolbarView { + [top] + Adw.HeaderBar header_bar { + show-title: false; + [end] + MenuButton more_menu_button { + icon-name: "view-more-symbolic"; + popover: more_menu; + } + } + ScrolledWindow scrolled_window { + Adw.Clamp { + Box { + margin-start: 12; + margin-end: 12; + margin-bottom: 12; + orientation: vertical; + halign: fill; + + Image app_icon { + pixel-size: 100; + margin-top: 6; + margin-bottom: 18; + icon-name: "application-x-executable-symbolic"; + styles["icon-dropshadow"] + } + + Label name { + styles ["title-1"] + wrap: true; + wrap-mode: word_char; + justify: center; + margin-bottom: 12; + margin-start: 6; + margin-end: 6; + } + + Label description { + styles ["title-4"] + wrap: true; + wrap-mode: word_char; + justify: center; + margin-start: 6; + margin-end: 6; + } + + Box { + spacing: 6; + homogeneous: true; + margin-top: 18; + margin-bottom: 12; + halign: center; + Button open_app_button { + styles ["suggested-action", "pill"] + can-shrink: true; + label: _("Open"); + } + Button uninstall_button { + styles ["pill"] + can-shrink: true; + label: _("Uninstall"); + } + } + + Box eol_box { + margin-bottom: 12; + styles ["card"] + Label status_label { + margin-top: 6; + margin-bottom: 7; + margin-start: 6; + margin-end: 6; + label: _("This package is End Of Life, and will not recieve any security updates"); + styles ["heading", "error"] + halign: center; + hexpand: true; + wrap: true; + justify: center; + } + } + + Box information { + orientation: vertical; + Adw.PreferencesGroup actions { + margin-bottom: 12; + Adw.ActionRow data_row { + title: _("User Data"); + styles["property"] + + [suffix] + Button open_data_button { + styles["flat"] + valign: center; + icon-name: "folder-open-symbolic"; + tooltip-text: _("Open User Data"); + } + + [suffix] + Button trash_data_button { + styles["flat"] + valign: center; + icon-name: "user-trash-symbolic"; + tooltip-text: _("Trash User Data"); + } + + [suffix] + Spinner data_spinner { + spinning: true; + } + } + Adw.ExpanderRow version_row { + title: _("Version"); + styles ["property"] + [suffix] + Label mask_label { + label: _("Updates Disabled"); + styles["warning"] + } + Adw.ActionRow mask_row { + title: _("Disable Updates"); + subtitle: _("Mask this package so it's never updated"); + activatable: true; + Gtk.Switch mask_switch { + valign: center; + can-focus: false; + can-target: false; + } + } + Adw.ActionRow change_version_row { + title: _("Change Version"); + subtitle: _("Upgrade or downgrade this package"); + activatable: true; + Image { + icon-name: "right-large-symbolic"; + } + } + } + Adw.ActionRow installed_size_row { + styles ["property"] + title: _("Installed Size"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow runtime_row { + styles ["property"] + title: _("Runtime"); + activatable: true; + Image eol_package_package_status_icon { + icon-name: "error-symbolic"; + tooltip-text: _("This package is End Of Life, and will not recieve any security updates"); + margin-end: 6; + styles["error"] + } + Image { + icon-name: "right-large-symbolic"; + } + } + Adw.ActionRow pin_row { + title: _("Disable Automactic Removal"); + subtitle: _("Pin this runtime to keep it installed"); + activatable: true; + Gtk.Switch pin_switch { + valign: center; + can-focus: false; + can-target: false; + } + } + } + Adw.PreferencesGroup package_info { + margin-bottom: 12; + title: _("Package Information"); + Adw.ActionRow id_row { + styles ["property"] + title: _("Application ID"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow ref_row { + styles ["property"] + title: "Ref"; + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow arch_row { + styles ["property"] + title: _("Architecture"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow branch_row { + styles ["property"] + title: _("Branch"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow license_row { + styles ["property"] + title: _("License"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + } + Adw.PreferencesGroup remote_info { + margin-bottom: 12; + title: _("Installation Information"); + Adw.ActionRow sdk_row { + styles ["property"] + title: "SDK"; + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow origin_row { + styles ["property"] + title: _("Origin"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow collection_row { + styles ["property"] + title: _("Collection"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow installation_row { + styles ["property"] + title: _("Installation"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + } + Adw.PreferencesGroup commit_info { + title: _("Commit Information"); + Adw.ActionRow commit_row { + styles ["property"] + title: "Commit"; + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow parent_row { + styles ["property"] + title: _("Parent"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow subject_row { + styles ["property"] + title: _("Subject"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + Adw.ActionRow date_row { + styles ["property"] + title: _("Date"); + activatable: true; + Image { + icon-name: "copy-symbolic"; + } + } + } + } + } + } + } + } + } + } + } + } +} + +Popover more_menu { + styles ["menu"] + ListBox more_list { + } +} diff --git a/src/properties_page/properties_page.py b/src/properties_page/properties_page.py new file mode 100644 index 0000000..0d5fc01 --- /dev/null +++ b/src/properties_page/properties_page.py @@ -0,0 +1,390 @@ +from gi.repository import Adw, Gtk, GLib, Gio +from .error_toast import ErrorToast +from .host_info import HostInfo +from .change_version_page import ChangeVersionPage +from .uninstall_dialog import UninstallDialog +from .loading_status import LoadingStatus +from .package_install_worker import PackageInstallWorker +import subprocess, os + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/properties_page/properties_page.ui") +class PropertiesPage(Adw.NavigationPage): + __gtype_name__ = 'PropertiesPage' + gtc = Gtk.Template.Child + stack = gtc() + error_tbv = gtc() + loading_tbv = gtc() + + more_menu = gtc() + more_list = gtc() + + nav_view = gtc() + inner_nav_page = gtc() + toast_overlay = gtc() + header_bar = gtc() + scrolled_window = gtc() + app_icon = gtc() + name = gtc() + description = gtc() + eol_box = gtc() + open_app_button = gtc() + uninstall_button = gtc() + + pin_row = gtc() + pin_switch = gtc() + data_row = gtc() + open_data_button = gtc() + trash_data_button = gtc() + data_spinner = gtc() + version_row = gtc() + mask_label = gtc() + mask_row = gtc() + mask_switch = gtc() + change_version_row = gtc() + installed_size_row = gtc() + runtime_row = gtc() + eol_package_package_status_icon = gtc() + + id_row = gtc() + ref_row = gtc() + arch_row = gtc() + branch_row = gtc() + license_row = gtc() + + sdk_row = gtc() + origin_row = gtc() + collection_row = gtc() + installation_row = gtc() + + commit_row = gtc() + parent_row = gtc() + subject_row = gtc() + date_row = gtc() + + package = None + + def set_properties(self, package, refresh=False): + if package == self.package and not refresh: + # Do not update the ui if the same app row is clicked + return + + self.reinstall_did_error = False + self.package = package + pkg_name = package.info["name"] + if pkg_name != "": + self.inner_nav_page.set_title(_("{} Properties").format(package.info["name"])) + self.name.set_visible(True) + self.name.set_label(pkg_name) + else: + self.name.set_visible(False) + self.inner_nav_page.set_title(_("Properties")) + + if package.icon_path: + GLib.idle_add(lambda *_: self.app_icon.set_from_file(package.icon_path)) + else: + GLib.idle_add(lambda *_: self.app_icon.set_from_icon_name("application-x-executable-symbolic")) + + self.eol_box.set_visible(package.is_eol) + self.pin_row.set_visible(package.is_runtime) + self.open_app_button.set_visible(package.is_runtime) + self.open_app_button.set_visible(not package.is_runtime) + self.data_row.set_visible(not package.is_runtime) + self.uninstall_button.set_sensitive(self.package.info['id'] != "io.github.flattool.Warehouse") + if package.is_runtime: + self.runtime_row.set_visible(False) + else: + has_path = os.path.exists(package.data_path) + self.trash_data_button.set_sensitive(has_path and self.package.info['id'] != "io.github.flattool.Warehouse") + self.open_data_button.set_sensitive(has_path) + + if not self.package.dependant_runtime is None: + self.runtime_row.set_visible(True) + self.runtime_row.set_subtitle(self.package.dependant_runtime.info["name"]) + self.eol_package_package_status_icon.set_visible(self.package.dependant_runtime.is_eol) + + if has_path: + self.trash_data_button.set_visible(False) + self.open_data_button.set_visible(False) + self.data_spinner.set_visible(True) + self.data_row.set_subtitle(_("Loading User Data")) + + def callback(size): + self.trash_data_button.set_visible(True) + self.open_data_button.set_visible(True) + self.data_spinner.set_visible(False) + self.data_row.set_subtitle(size) + + self.package.get_data_size(lambda size: callback(size)) + else: + self.data_row.set_subtitle(_("No User Data")) + self.data_spinner.set_visible(False) + + cli_info = None + try: + cli_info = package.get_cli_info() + pkg_description = package.cli_info["description"] + self.description.set_visible(pkg_description != "") + self.description.set_label(pkg_description) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not get properties"), str(e)).toast) + return + + for key, row in self.info_rows.items(): + row.set_visible(False) + + try: + subtitle = cli_info[key] + row.set_subtitle(subtitle) + row.set_visible(True) + except KeyError: + if key == "version": + row.set_visible(True) + row.set_subtitle(_("No version information found")) + continue + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not get properties"), str(e)).toast) + continue + + self.mask_label.set_visible(package.is_masked) + self.mask_switch.set_active(package.is_masked) + self.pin_switch.set_active(package.is_pinned) + GLib.idle_add(lambda *_: self.stack.set_visible_child(self.nav_view)) + self.more_list.remove_all() + if self.open_app_button.get_visible(): + self.more_list.append(self.view_snapshots) + self.more_list.append(self.copy_launch_command) + + self.more_list.append(self.show_details) + self.more_list.append(self.reinstall) + + def open_data_handler(self, *args): + if error := self.package.open_data(): + self.toast_overlay.add_toast(ErrorToast(_("Could not open data"), str(error)).toast) + + def trash_data_handler(self, *args): + def on_choice(dialog, response): + if response != 'continue': + return + try: + self.package.trash_data() + self.set_properties(self.package, refresh=True) + self.toast_overlay.add_toast(Adw.Toast.new("Trashed User Data")) + user_data_page = HostInfo.main_window.pages[HostInfo.main_window.user_data_row] + user_data_page.start_loading() + user_data_page.end_loading() + snapshot_list_page = HostInfo.main_window.pages[HostInfo.main_window.snapshots_row].list_page + snapshot_list_package = snapshot_list_page.package_or_folder + if not snapshot_list_package is None: + snapshot_list_page.set_snapshots(snapshot_list_package, True) + + except subprocess.CalledProcessError as cpe: + self.toast_overlay.add_toast(ErrorToast(_("Could not trash data"), cpe.stderr).toast) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not trash data"), str(e)).toast) + + dialog = Adw.AlertDialog( + heading=_("Send {}'s User Data to the Trash?").format(self.package.info["name"]), + body=_("Your settings and data for this app will be sent to the trash") + ) + dialog.add_response('cancel', _("Cancel")) + dialog.add_response('continue', _("Trash Data")) + dialog.connect("response", on_choice) + dialog.set_response_appearance('continue', Adw.ResponseAppearance.DESTRUCTIVE) + dialog.present(self.main_window) + + def set_mask_handler(self, *args): + state = not self.mask_switch.get_active() + def callback(*args): + if fail := self.package.failed_mask: + response = _("Could not Disable Updates") if state else _("Could not Enable Updates") + fail = fail.stderr if type(fail) == subprocess.CalledProcessError else fail + self.toast_overlay.add_toast(ErrorToast(response, str(fail)).toast) + GLib.idle_add(lambda *_: self.mask_switch.set_active(not state)) + GLib.idle_add(lambda *_: self.mask_label.set_visible(not state)) + else: + response = _("Disabled Updates") if state else _("Enabled Updates") + self.toast_overlay.add_toast(Adw.Toast(title=response)) + GLib.idle_add(lambda *_: self.mask_switch.set_active(state)) + GLib.idle_add(lambda *_: self.mask_label.set_visible(state)) + self.package.app_row.masked_status_icon.set_visible(state) + + self.package.set_mask(state, callback) + + def set_pin_handler(self, *args): + state = not self.pin_switch.get_active() + def callback(*args): + if fail := self.package.failed_pin: + response = _("Could not Disable Autoremoval") if state else _("Could not Enable Autoremoval") + fail = fail.stderr if type(fail) == subprocess.CalledProcessError else fail + self.toast_overlay.add_toast(ErrorToast(response, str(fail)).toast) + GLib.idle_add(lambda *_: self.pin_switch.set_active(not state)) + else: + response = _("Disabled Autoremoval") if state else _("Enabled Autoremoval") + self.toast_overlay.add_toast(Adw.Toast(title=response)) + GLib.idle_add(lambda *_: self.pin_switch.set_active(state)) + self.package.app_row.pinned_status_icon.set_visible(state) + + self.package.set_pin(state, callback) + + def uninstall_handler(self, *args): + def on_choice(should_trash): + self.packages_page.set_status(self.packages_page.uninstalling) + self.package.uninstall(callback) + if should_trash: + try: + self.package.trash_data() + self.set_properties(self.package, refresh=True) + except subprocess.CalledProcessError as cpe: + self.toast_overlay.add_toast(ErrorToast(_("Could not trash data"), cpe.stderr).toast) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not trash data"), str(e)).toast) + + def callback(*args): + if fail := self.package.failed_uninstall: + fail = fail.stderr if type(fail) == subprocess.CalledProcessError else fail + self.toast_overlay.add_toast(ErrorToast(_("Could not uninstall"), str(fail)).toast) + self.packages_page.set_status(self.packages_page.scrolled_window) + else: + self.main_window.refresh_handler() + HostInfo.main_window.toast_overlay.add_toast(Adw.Toast(title=_("Uninstalled {}").format(self.package.info["name"]))) + + dialog = UninstallDialog(on_choice, os.path.exists(self.package.data_path), self.package.info["name"]) + dialog.present(self.main_window) + + def runtime_row_handler(self, *args): + new_page = self.__class__() + new_page.packages_page = self.packages_page + new_page.set_properties(self.package.dependant_runtime) + self.nav_view.push(new_page) + + def open_app_handler(self, *args): + self.toast_overlay.add_toast(Adw.Toast(title=_("Openeing {}â€Ļ").format(self.package.info["name"]))) + + def callback(*args): + if fail := self.package.failed_app_run: + fail = fail.stderr if type(fail) == subprocess.CalledProcessError else fail + self.toast_overlay.add_toast(ErrorToast(_("Could not open {}").format(self.package.info["name"]), str(fail)).toast) + + self.package.open_app(callback) + + def copy_handler(self, row): + HostInfo.clipboard.set(row.get_subtitle()) + self.toast_overlay.add_toast(Adw.Toast(title=_("Copied {}").format(row.get_title()))) + + def change_version_handler(self, row): + page = ChangeVersionPage(self.packages_page, self.package) + self.nav_view.push(page) + + def reinstall_callback(self): + HostInfo.main_window.refresh_handler() + if not self.reinstall_did_error: + HostInfo.main_window.toast_overlay.add_toast(Adw.Toast(title=_("Reinstalled {}").format(self.package.info['name']))) + + def reinstall_error_callback(self, user_facing_label, error_message): + self.reinstall_did_error = True + GLib.idle_add(lambda *_: HostInfo.main_window.toast_overlay.add_toast(ErrorToast(user_facing_label, error_message).toast)) + + def reinstall_handler(self): + def on_response(dialog, response): + if response != "continue": + return + + self.reinstall_did_error = False + PackageInstallWorker.install( + [{ + "installation": self.package.info['installation'], + "remote": self.package.info['origin'], + "package_names": [self.package.info['ref']], + "extra_flags": ["--reinstall"], + }], + self.packages_page.reinstalling, + self.reinstall_callback, + self.reinstall_error_callback, + ) + self.packages_page.set_status(self.packages_page.reinstalling) + + dialog = Adw.AlertDialog( + heading=_("Reinstall {}?").format(self.package.info['name']), + body=_("This package will be uninstalled, and then reinstalled from the same remote and installation.") + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("continue", _("Reinstall")) + dialog.set_response_appearance("continue", Adw.ResponseAppearance.SUGGESTED) + dialog.connect("response", on_response) + dialog.present(HostInfo.main_window) + + def more_menu_handler(self, listbox, row): + self.more_menu.popdown() + match row.get_child(): + case self.view_snapshots: + snapshots_row = HostInfo.main_window.snapshots_row + snapshots_page = HostInfo.main_window.pages[snapshots_row] + HostInfo.main_window.activate_row(snapshots_row) + snapshots_page.show_snapshot(self.package) + + case self.copy_launch_command: + try: + HostInfo.clipboard.set(f"flatpak run {self.package.info['ref']}") + self.toast_overlay.add_toast(Adw.Toast.new(_("Copied launch command"))) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not copy launch command"), str(e)).toast) + + case self.show_details: + try: + Gio.AppInfo.launch_default_for_uri(f"appstream://{self.package.info['id']}", None) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not show details"), str(e)).toast) + + case self.reinstall: + self.reinstall_handler() + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.main_window = HostInfo.main_window + self.info_rows = { + "version": self.version_row, + "installed": self.installed_size_row, + + "id": self.id_row, + "ref": self.ref_row, + "arch": self.arch_row, + "branch": self.branch_row, + "license": self.license_row, + + "sdk": self.sdk_row, + "origin": self.origin_row, + "collection": self.collection_row, + "installation": self.installation_row, + + "commit": self.commit_row, + "parent": self.parent_row, + "subject": self.subject_row, + "date": self.date_row, + } + self.loading_tbv.set_content(LoadingStatus(_("Loading Properties"), _("This should only take a moment"))) + self.packages_page = None # To be set in packages page + self.__class__.main_window = self.main_window + self.view_snapshots = Gtk.Label(halign=Gtk.Align.START, label=_("View Snapshots")) + self.copy_launch_command = Gtk.Label(halign=Gtk.Align.START, label=_("Copy Launch Command")) + self.show_details = Gtk.Label(halign=Gtk.Align.START, label=_("Show Details")) + self.reinstall = Gtk.Label(halign=Gtk.Align.START, label=_("Reinstall")) + self.reinstall_did_error = False + + # Apply + + # Connections + self.more_list.connect("row-activated", self.more_menu_handler) + self.open_data_button.connect("clicked", self.open_data_handler) + self.scrolled_window.get_vadjustment().connect("value-changed", lambda adjustment: self.header_bar.set_show_title(not adjustment.get_value() == 0)) + self.trash_data_button.connect("clicked", self.trash_data_handler) + self.runtime_row.connect("activated", self.runtime_row_handler) + self.open_app_button.connect("clicked", self.open_app_handler) + self.uninstall_button.connect("clicked", self.uninstall_handler) + self.mask_row.connect("activated", self.set_mask_handler) + self.pin_row.connect("activated", self.set_pin_handler) + self.change_version_row.connect("activated", self.change_version_handler) + for key, row in self.info_rows.items(): + if type(row) is Adw.ActionRow: + row.connect("activated", self.copy_handler) diff --git a/src/properties_window.py b/src/properties_window.py deleted file mode 100644 index 20c71b3..0000000 --- a/src/properties_window.py +++ /dev/null @@ -1,222 +0,0 @@ -from gi.repository import Gtk, Adw, GLib, Gdk, Gio -from .common import myUtils -import subprocess -import os -import pathlib - - -@Gtk.Template(resource_path="/io/github/flattool/Warehouse/../data/ui/properties.ui") -class PropertiesWindow(Adw.Dialog): - __gtype_name__ = "PropertiesWindow" - - new_env = dict(os.environ) - new_env["LC_ALL"] = "C" - host_home = str(pathlib.Path.home()) - user_data_path = host_home + "/.var/app/" - - toast_overlay = Gtk.Template.Child() - upper = Gtk.Template.Child() - app_icon = Gtk.Template.Child() - data_row = Gtk.Template.Child() - open_data = Gtk.Template.Child() - trash_data = Gtk.Template.Child() - spinner = Gtk.Template.Child() - details = Gtk.Template.Child() - view_apps = Gtk.Template.Child() - runtime = Gtk.Template.Child() - runtime_properties = Gtk.Template.Child() - runtime_copy = Gtk.Template.Child() - lower = Gtk.Template.Child() - eol_app_banner = Gtk.Template.Child() - eol_runtime_banner = Gtk.Template.Child() - mask_banner = Gtk.Template.Child() - name = Gtk.Template.Child() - description = Gtk.Template.Child() - description_button = Gtk.Template.Child() - - def copy_item(self, to_copy, to_toast=None): - self.get_clipboard().set(to_copy) - if to_toast: - self.toast_overlay.add_toast(Adw.Toast.new(_("Copied {}").format(to_toast))) - - def open_button_handler(self, widget): - try: - Gio.AppInfo.launch_default_for_uri(f"file://{self.user_data_path}", None) - except GLib.GError: - self.toast_overlay.add_toast(Adw.Toast.new(_("Could not open folder"))) - - def show_details(self, widget): - try: - Gio.AppInfo.launch_default_for_uri(f"appstream://{self.app_id}", None) - except GLib.GError: - self.toast_overlay.add_toast(Adw.Toast.new(_("Could not show details"))) - - def get_size_callback(self, *args): - self.open_data.set_visible(True) - self.open_data.connect("clicked", self.open_button_handler) - self.trash_data.set_visible(True) - self.data_row.set_title(_("User Data")) - self.data_row.set_subtitle(f"~{self.size}") - self.spinner.set_visible(False) - - def get_size_thread(self, *args): - self.size = self.my_utils.get_size_with_format(self.user_data_path) - - def generate_upper(self): - self.description_button.connect( - "clicked", - lambda *_a: self.copy_item( - self.description.get_label(), _("Description") - ) - ) - image = self.my_utils.find_app_icon(self.app_id) - self.runtime.set_subtitle(self.current_flatpak[13]) - if image.get_paintable() == None: - self.app_icon.set_from_icon_name(image.get_icon_name()) - else: - self.app_icon.set_from_paintable(image.get_paintable()) - - if os.path.exists(self.user_data_path): - task = Gio.Task.new(None, None, self.get_size_callback) - task.run_in_thread(self.get_size_thread) - else: - self.data_row.set_title("") - self.data_row.set_subtitle(_("No User Data")) - self.spinner.set_visible(False) - - if "runtime" in self.current_flatpak[12]: - # Pak is a runtime - self.runtime.set_visible(False) - if self.app_ref in self.parent_window.dependent_runtimes: - self.view_apps.set_visible(True) - - def generate_lower(self): - info = self.my_utils.get_flatpak_info(self.app_ref, self.install_type) - name_desc = info["name"].split(" - ") - if len(name_desc[0]) > 0: - self.name.set_label((name_desc[0])) - else: - self.name.set_visible(False) - - try: - self.description.set_label((name_desc[1])) - except: - self.description.set_visible(False) - - for key in info.keys(): - if key == "name": - continue - row = Adw.ActionRow( - title=GLib.markup_escape_text(key), - subtitle=GLib.markup_escape_text(info[key]), - activatable=True, - ) - row.add_suffix(Gtk.Image.new_from_icon_name("edit-copy-symbolic")) - row.add_css_class("property") - row.connect( - "activated", - lambda *_a, row=row: self.copy_item( - row.get_subtitle(), row.get_title() - ), - ) - self.lower.add(row) - - def view_apps_handler(self, widget): - settings = Gio.Settings.new("io.github.flattool.Warehouse.filter") - for key in settings.list_keys(): - settings.reset(key) - settings.set_string("runtimes-list", f"{self.app_ref},") - self.parent_window.apply_filter() - self.close() - - def show_properties_handler(self): - runtime = self.current_flatpak[13] - for i in range(len(self.host_flatpaks)): - # open the properties when the flatpak matches the runtime *and* installation type - if runtime in self.host_flatpaks[i][8] and self.install_type in self.host_flatpaks[i][12]: - PropertiesWindow(i, self.host_flatpaks, self.parent_window) - self.close() - return - self.toast_overlay.add_toast(Adw.Toast.new(_("Could not show properties"))) - - def trash_data_handler(self): - def on_response(_none, response, widget): - if response == "cancel": - return - - if self.my_utils.trash_folder(self.user_data_path) == 0: - self.toast_overlay.add_toast(Adw.Toast.new(_("Trashed user data"))) - self.data_row.set_title(_("No User Data")) - self.data_row.set_subtitle("") - self.open_data.set_visible(False) - self.trash_data.set_visible(False) - else: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not trash user data")) - ) - - dialog = Adw.AlertDialog.new( - _("Send {}'s User Data to the Trash?").format(self.app_name) - ) - dialog.add_response("cancel", _("Cancel")) - dialog.set_close_response("cancel") - dialog.add_response("continue", _("Trash Data")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.connect("response", on_response, dialog.choose_finish) - dialog.present(self) - - def __init__(self, flatpak_index, host_flatpaks, parent_window, **kwargs): - super().__init__(**kwargs) - self.my_utils = myUtils(self) - self.current_flatpak = host_flatpaks[flatpak_index] - self.parent_window = parent_window - self.host_flatpaks = host_flatpaks - self.flatpak_index = flatpak_index - - self.app_name = self.current_flatpak[0] - self.app_id = self.current_flatpak[2] - self.origin_remote = self.current_flatpak[6] - self.install_type = self.current_flatpak[7] - self.app_ref = self.current_flatpak[8] - self.user_data_path += self.app_id - - self.details.connect("activated", self.show_details) - self.runtime_copy.connect( - "clicked", - lambda *_: self.copy_item( - self.runtime.get_subtitle(), self.runtime.get_title() - ), - ) - self.runtime_properties.connect( - "clicked", lambda *_: self.show_properties_handler() - ) - self.view_apps.connect("activated", self.view_apps_handler) - self.trash_data.connect("clicked", lambda *_: self.trash_data_handler()) - - if "eol" in self.current_flatpak[12]: - self.eol_app_banner.set_revealed(True) - self.eol_app_banner.set_title( - _( - "{} has reached its End of Life and will not receive any security updates" - ).format(self.app_name) - ) - - if self.current_flatpak[13] in parent_window.eol_list: - self.eol_runtime_banner.set_revealed(True) - self.eol_runtime_banner.set_title( - _( - "{}'s runtime has reached its End of Life and will not receive any security updates" - ).format(self.app_name) - ) - - if self.app_id in self.my_utils.get_host_masks( - "system" - ) or self.app_id in self.my_utils.get_host_masks("user"): - self.mask_banner.set_revealed(True) - self.mask_banner.set_title( - _("{} is masked and will not be updated").format(self.app_name) - ) - - self.generate_upper() - self.generate_lower() - self.present(parent_window) diff --git a/src/remotes_page/add_remote_dialog.blp b/src/remotes_page/add_remote_dialog.blp new file mode 100644 index 0000000..dde66c1 --- /dev/null +++ b/src/remotes_page/add_remote_dialog.blp @@ -0,0 +1,56 @@ +using Gtk 4.0; +using Adw 1; + +template $AddRemoteDialog : Adw.Dialog { + title: _("Add a Remote"); + // content-width: 500; + // content-height: 375; + // width-request: 400; + follows-content-size: true; + Adw.ToolbarView { + [top] + Adw.HeaderBar { + show-start-title-buttons: false; + show-end-title-buttons: false; + [start] + Button cancel_button { + label: _("Cancel"); + } + [end] + Button apply_button { + styles ["suggested-action"] + label: _("Add"); + } + } + Adw.ToastOverlay toast_overlay { + ScrolledWindow content_page { + propagate-natural-height: true; + propagate-natural-width: true; + Adw.Clamp { + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + Box { + orientation: vertical; + spacing: 12; + Adw.PreferencesGroup { + styles ["boxed-list"] + Adw.EntryRow title_row { + title: _("Title"); + } + Adw.EntryRow name_row { + title: _("Name"); + } + Adw.EntryRow url_row { + title: _("Repo URL"); + } + } + $InstallationChooser installation_chooser { + } + } + } + } + } + } +} diff --git a/src/remotes_page/add_remote_dialog.py b/src/remotes_page/add_remote_dialog.py new file mode 100644 index 0000000..8b31e8c --- /dev/null +++ b/src/remotes_page/add_remote_dialog.py @@ -0,0 +1,131 @@ +from gi.repository import Adw, Gtk, Gio +from .host_info import HostInfo +from .error_toast import ErrorToast +from .loading_status import LoadingStatus +from .installation_chooser import InstallationChooser +import subprocess, re + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/remotes_page/add_remote_dialog.ui") +class AddRemoteDialog(Adw.Dialog): + __gtype_name__ = "AddRemoteDialog" + gtc = Gtk.Template.Child + + toast_overlay = gtc() + cancel_button = gtc() + apply_button = gtc() + content_page = gtc() + title_row = gtc() + name_row = gtc() + url_row = gtc() + installation_chooser = gtc() + is_open = False + + def on_apply(self, *args): + self.parent_page.status_stack.set_visible_child(self.parent_page.adding_view) + self.apply_button.set_sensitive(False) + error = [None] + def thread(*args): + HostInfo.main_window.add_refresh_lockout("adding remote") + cmd = [ + 'flatpak-spawn', '--host', + 'flatpak', 'remote-add', + f'--title={self.title_row.get_text()}', + self.name_row.get_text(), + self.url_row.get_text(), + ] + installation = self.installation_chooser.get_installation() + if installation == "user" or installation == "system": + cmd.append(f"--{installation}") + else: + cmd.append(f"--installation={installation}") + + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as cpe: + error[0] = cpe.stderr + except Exception as e: + error[0] = e + + def callback(*args): + HostInfo.main_window.remove_refresh_lockout("adding remote") + if error[0]: + self.parent_page.status_stack.set_visible_child(self.parent_page.main_view) + self.apply_button.set_sensitive(True) + self.parent_page.toast_overlay.add_toast(ErrorToast(_("Could not add remote"), str(error[0])).toast) + else: + self.main_window.refresh_handler() + self.parent_page.toast_overlay.add_toast(Adw.Toast(title=_("Added {}").format(self.title_row.get_text()))) + + Gio.Task.new(None, None, callback).run_in_thread(thread) + self.close() + + def check_entries(self, row): + is_passing = re.match(self.rexes[row], row.get_text()) + if is_passing: + row.remove_css_class("error") + else: + row.add_css_class("error") + + match row: + case self.title_row: + self.title_passes = bool(is_passing) + case self.name_row: + self.name_passes = bool(is_passing) + case self.url_row: + self.url_passes = bool(is_passing) + + self.apply_button.set_sensitive(self.title_passes and self.name_passes and self.url_passes) + + def present(self, *args, **kwargs): + if self.__class__.is_open: + return + + self.__class__.is_open = True + super().present(*args, **kwargs) + + def on_close(self, *args): + self.__class__.is_open = False + + def __init__(self, main_window, parent_page, remote_info=None, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.string_list = Gtk.StringList(strings=HostInfo.installations) + self.main_window = main_window + self.parent_page = parent_page + + self.rexes = { + self.title_row: r"^(?=.*[A-Za-z0-9])[A-Za-z0-9._-]+( +[A-Za-z0-9._-]+)*$", + self.name_row: r"^[a-zA-Z0-9\-._]+$", + self.url_row: r"^[a-zA-Z0-9\-._~:/?#[\]@!$&\'()*+,;= ]+$", + } + self.title_passes = False + self.name_passes = False + self.url_passes = False + + # Apply + self.installation_chooser.set_content_strings(_("remote"), False) + if remote_info: + self.title_row.set_text(remote_info["title"]) + self.name_row.set_text(remote_info["name"]) + self.url_row.set_text(remote_info["link"]) + if remote_info["description"] == "local file": + self.check_entries(self.title_row) + self.check_entries(self.name_row) + self.check_entries(self.url_row) + self.url_row.set_editable(False) + else: + self.title_row.set_editable(False) + self.name_row.set_editable(False) + self.url_row.set_editable(False) + self.apply_button.set_sensitive(True) + else: + self.apply_button.set_sensitive(False) + + # Connections + self.connect("closed", self.on_close) + self.cancel_button.connect("clicked", lambda *_: self.close()) + self.apply_button.connect("clicked", self.on_apply) + self.title_row.connect("changed", self.check_entries) + self.name_row.connect("changed", self.check_entries) + self.url_row.connect("changed", self.check_entries) diff --git a/src/remotes_page/remote_row.blp b/src/remotes_page/remote_row.blp new file mode 100644 index 0000000..c32c455 --- /dev/null +++ b/src/remotes_page/remote_row.blp @@ -0,0 +1,57 @@ +using Gtk 4.0; +using Adw 1; + +template $RemoteRow : Adw.ActionRow { + [suffix] + Label suffix_label { + styles ["subtitle"] + margin-end: 6; + wrap: true; + wrap-mode: word_char; + natural-wrap-mode: none; + halign: end; + hexpand: true; + justify: right; + } + [suffix] + Button filter_button { + styles ["flat"] + valign: center; + icon-name: "funnel-symbolic"; + tooltip-text: _("Set a Filter for this Remote"); + } + [suffix] + MenuButton menu_button { + styles ["flat"] + valign: center; + popover: menu_pop; + icon-name: "view-more-symbolic"; + tooltip-text: _("More Actions"); + } +} + +Popover menu_pop { + styles ["menu"] + ListBox menu_listbox { + Label copy_title { + label: _("Copy Title"); + halign: start; + } + Label copy_name { + label: _("Copy Name"); + halign: start; + } + Label enable_remote { + label: _("Enable"); + halign: start; + } + Label disable_remote { + label: _("Disable"); + halign: start; + } + Label remove { + label: _("Remove"); + halign: start; + } + } +} diff --git a/src/remotes_page/remote_row.py b/src/remotes_page/remote_row.py new file mode 100644 index 0000000..0a0b2dd --- /dev/null +++ b/src/remotes_page/remote_row.py @@ -0,0 +1,172 @@ +from gi.repository import Adw, Gtk, GLib, Gio +from .host_info import HostInfo +from .error_toast import ErrorToast +import subprocess + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/remotes_page/remote_row.ui") +class RemoteRow(Adw.ActionRow): + __gtype_name__ = 'RemoteRow' + gtc = Gtk.Template.Child + + suffix_label = gtc() + filter_button = gtc() + menu_pop = gtc() + menu_listbox = gtc() + + copy_title = gtc() + copy_name = gtc() + enable_remote = gtc() + disable_remote = gtc() + remove = gtc() + + def enable_remote_handler(self, *args): + if not self.remote.disabled: + self.parent_page.toast_overlay.add_toast(ErrorToast(_("Could not enable remote"), _("Remote is already enabled")).toast) + return + + has_error = [] + def thread(*args): + cmd = ['flatpak-spawn', '--host', 'flatpak', 'remote-modify', '--enable', self.remote.name] + if self.installation == "user" or self.installation == "system": + cmd.append(f"--{self.installation}") + else: + cmd.append(f"--installation={self.installation}") + + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as cpe: + has_error.append(str(cpe.stderr)) + except Exception as e: + has_error.append(str(e)) + + def callback(*args): + if len(has_error) > 0: + GLib.idle_add(lambda *args, cpe=cpe: self.parent_page.toast_overlay.add_toast(ErrorToast(_("Could not enable remote"), has_error[0]).toast)) + return + + self.remove_css_class("warning") + self.set_icon_name("") + self.set_tooltip_text("") + self.remote.disabled = False + self.parent_page.toast_overlay.add_toast(Adw.Toast(title=_("Enabled remote"))) + self.menu_listbox.get_row_at_index(2).set_visible(False) + self.menu_listbox.get_row_at_index(3).set_visible(True) + self.parent_page.total_disabled -= 1 + install_page = HostInfo.main_window.pages[HostInfo.main_window.install_row] + install_page.start_loading() + install_page.end_loading() + filters_page = HostInfo.main_window.pages[HostInfo.main_window.packages_row].filters_page + filters_page.generate_filters() + if self.parent_page.total_disabled == 0: + self.parent_page.show_disabled_button.set_active(False) + self.parent_page.show_disabled_button.set_visible(False) + + Gio.Task.new(None, None, callback).run_in_thread(thread) + + def disable_remote_handler(self, *args): + error = [None] + + def callback(*args): + if error[0]: + return + + self.add_css_class("warning") + self.set_icon_name("error-symbolic") + self.set_tooltip_text(_("Remote is Disabled")) + self.remote.disabled = True + self.parent_page.toast_overlay.add_toast(Adw.Toast(title=_("Disabled remote"))) + self.menu_listbox.get_row_at_index(2).set_visible(True) + self.menu_listbox.get_row_at_index(3).set_visible(False) + self.set_visible(self.parent_page.show_disabled_button.get_active()) + self.parent_page.show_disabled_button.set_visible(True) + self.parent_page.total_disabled += 1 + self.parent_page.none_visible_handler() + install_page = HostInfo.main_window.pages[HostInfo.main_window.install_row] + install_page.start_loading() + install_page.end_loading() + filters_page = HostInfo.main_window.pages[HostInfo.main_window.packages_row].filters_page + filters_page.settings.reset("remotes-list") + filters_page.all_remotes_switch.set_active(False) + filters_page.generate_filters() + filters_page.packages_page.apply_filters() + + def thread(*args): + if self.remote.disabled: + self.parent_page.toast_overlay.add_toast(ErrorToast(_("Could not disable remote"), _("Remote is already disabled")).toast) + return + + cmd = ['flatpak-spawn', '--host', 'flatpak', 'remote-modify', '--disable', self.remote.name] + if self.installation == "user" or self.installation == "system": + cmd.append(f"--{self.installation}") + else: + cmd.append(f"--installation={self.installation}") + + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as cpe: + GLib.idle_add(lambda *args, cpe=cpe: self.parent_page.toast_overlay.add_toast(ErrorToast(_("Could not disable remote"), str(cpe.stderr)).toast)) + error[0] = cpe + return + except Exception as e: + GLib.idle_add(lambda *args, e=e: self.parent_page.toast_overlay.add_toast(ErrorToast(_("Could not disable remote"), str(e)).toast)) + error[0] = e + return + + def on_response(_, response): + if response != "continue": + return + + Gio.Task.new(None, None, callback).run_in_thread(thread) + + dialog = Adw.AlertDialog(heading=_("Disable {}?").format(self.remote.title), body=_("Any installed apps from {} will stop receiving updates").format(self.remote.name)) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("continue", _("Disable")) + dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.connect("response", on_response) + dialog.present(self.parent_page.main_window) + + def on_menu_action(self, listbox, row): + row = row.get_child() + match row: + case self.copy_title: + HostInfo.clipboard.set(self.remote.title) + self.parent_page.toast_overlay.add_toast(Adw.Toast(title=_("Copied title"))) + case self.copy_name: + HostInfo.clipboard.set(self.remote.name) + self.parent_page.toast_overlay.add_toast(Adw.Toast(title=_("Copied name"))) + case self.enable_remote: + self.enable_remote_handler() + case self.disable_remote: + self.disable_remote_handler() + case self.remove: + self.parent_page.remove_remote(self) + + self.menu_pop.popdown() + + def idle_stuff(self): + self.set_title(self.remote.title) + self.set_subtitle(_("Installation: {}").format(self.installation)) + self.suffix_label.set_label(self.remote.name) + if self.remote.disabled: + self.set_icon_name("error-symbolic") + self.add_css_class("warning") + self.set_tooltip_text(_("Remote is Disabled")) + + def __init__(self, parent_page, installation, remote, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.parent_page = parent_page + self.remote = remote + self.installation = installation + + # Apply + GLib.idle_add(lambda *_: self.idle_stuff()) + + ## Show / Hide the Enable / Disable actions depending on remote status + self.menu_listbox.get_row_at_index(2).set_visible(remote.disabled) + self.menu_listbox.get_row_at_index(3).set_visible(not remote.disabled) + + # Connections + self.menu_listbox.connect("row-activated", self.on_menu_action) + self.filter_button.connect("clicked", lambda *_: parent_page.filter_remote(self)) diff --git a/src/remotes_page/remotes_page.blp b/src/remotes_page/remotes_page.blp new file mode 100644 index 0000000..04afc38 --- /dev/null +++ b/src/remotes_page/remotes_page.blp @@ -0,0 +1,161 @@ +using Gtk 4.0; +using Adw 1; + +template $RemotesPage : Adw.NavigationPage { + title: _("Manage Remotes"); + Adw.ToastOverlay toast_overlay { + Stack status_stack { + Adw.ToolbarView loading_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + Adw.ToolbarView adding_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + Adw.ToolbarView main_view { + [top] + Adw.HeaderBar header_bar { + [start] + $SidebarButton {} + [start] + ToggleButton search_button { + icon-name: "loupe-large-symbolic"; + tooltip-text: _("Search Packages"); + } + } + [top] + Adw.Clamp { + SearchBar search_bar { + search-mode-enabled: bind search_button.active bidirectional; + key-capture-widget: template; + SearchEntry search_entry { + hexpand: true; + placeholder-text: _("Search Remotes"); + } + } + } + Stack stack { + Adw.PreferencesPage content_page { + Adw.PreferencesGroup current_remotes_group { + title: _("Current Remotes"); + description: _("Remotes available on your system"); + header-suffix: + ToggleButton show_disabled_button { + valign: center; + styles ["flat"] + Adw.ButtonContent show_disabled_button_content { + icon-name: "eye-not-looking-symbolic"; + label: _("Show Disabled"); + } + } + ; + Adw.ActionRow none_visible { + styles ["warning"] + [child] + Box { + spacing: 3; + orientation: vertical; + Box { + halign: center; + Image { + valign: center; + margin-top: 7; + margin-end: 6; + icon-name: "eye-not-looking-symbolic"; + } + Label { + margin-top: 7; + label: _("No Enabled Remotes"); + wrap: true; + styles ["heading"] + } + } + Label { + label: _("You only have disabled remotes on this system"); + margin-start: 16; + margin-end: 16; + margin-bottom: 8; + justify: center; + halign: center; + wrap: true; + } + } + } + Adw.ActionRow no_remotes { + styles ["error"] + [child] + Box { + spacing: 3; + orientation: vertical; + Box { + halign: center; + Image { + valign: center; + margin-top: 7; + margin-end: 6; + icon-name: "error-symbolic"; + } + Label { + margin-top: 7; + label: _("No Remotes Found"); + wrap: true; + styles ["heading"] + } + } + Label { + label: _("Warehouse cannot see the current remotes or your system has no remotes added"); + margin-start: 16; + margin-end: 16; + margin-bottom: 8; + justify: center; + halign: center; + wrap: true; + } + } + } + } + Adw.PreferencesGroup new_remotes_group { + visible: bind search_button.active inverted; + title: _("Add Popular Remotes"); + description: _("Add new remotes to get more software"); + } + Adw.PreferencesGroup other_remotes { + visible: bind search_button.active inverted; + title: _("Add Other Remotes"); + Adw.ActionRow file_remote_row { + activatable: true; + title: _("Add a Repo File"); + subtitle: _("Open a downloaded repo file to add"); + [suffix] + Image { + icon-name: "plus-large-symbolic"; + } + } + Adw.ActionRow custom_remote_row { + activatable: true; + title: _("Add a Custom Remote"); + subtitle: _("Manually enter new remote details"); + [suffix] + Image { + icon-name: "plus-large-symbolic"; + } + } + } + } + Adw.StatusPage no_results { + title: _("No Results Found"); + description: _("Try a different search"); + icon-name: "system-search-symbolic"; + } + } + } + } + } +} diff --git a/src/remotes_page/remotes_page.py b/src/remotes_page/remotes_page.py new file mode 100644 index 0000000..9357400 --- /dev/null +++ b/src/remotes_page/remotes_page.py @@ -0,0 +1,290 @@ +from gi.repository import Adw, Gtk, GLib, Gio +from .host_info import HostInfo +from .error_toast import ErrorToast +from .remote_row import RemoteRow +from .add_remote_dialog import AddRemoteDialog +from .loading_status import LoadingStatus +import subprocess + +class NewRemoteRow(Adw.ActionRow): + __gtype_name__ = "NewRemoteRow" + + def idle_stuff(self, *args): + self.set_title(self.info["title"]) + self.set_subtitle(self.info["description"]) + self.add_suffix(Gtk.Image.new_from_icon_name("plus-large-symbolic")) + + def __init__(self, info, **kwargs): + super().__init__(**kwargs) + self.info = info + GLib.idle_add(self.idle_stuff) + self.set_activatable(True) + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/remotes_page/remotes_page.ui") +class RemotesPage(Adw.NavigationPage): + + # Preselected Remotes + new_remotes = [ + { + "title": "AppCenter", + "name": "appcenter", + "link": "https://flatpak.elementary.io/repo.flatpakrepo", + "description": _("The open source, pay-what-you-want app store from elementary") + }, + { + "title": "Flathub", + "name": "flathub", + "link": "https://dl.flathub.org/repo/flathub.flatpakrepo", + "description": _("Central repository of Flatpak applications"), + }, + { + "title": "Flathub beta", + "name": "flathub-beta", + "link": "https://flathub.org/beta-repo/flathub-beta.flatpakrepo", + "description": _("Beta builds of Flatpak applications"), + }, + { + "title": "Fedora", + "name": "fedora", + "link": "oci+https://registry.fedoraproject.org", + "description": _("Flatpaks packaged by Fedora Linux"), + }, + { + "title": "GNOME Nightly", + "name": "gnome-nightly", + "link": "https://nightly.gnome.org/gnome-nightly.flatpakrepo", + "description": _("The latest beta GNOME Apps and Runtimes"), + }, + { + "title": "WebKit Developer SDK", + "name": "webkit-sdk", + "link": "https://software.igalia.com/flatpak-refs/webkit-sdk.flatpakrepo", + "description": _("Central repository of the WebKit Developer and Runtime SDK"), + } + ] + + __gtype_name__ = 'RemotesPage' + gtc = Gtk.Template.Child + + search_button = gtc() + search_bar = gtc() + search_entry = gtc() + toast_overlay = gtc() + stack = gtc() + current_remotes_group = gtc() + show_disabled_button = gtc() + show_disabled_button_content = gtc() + new_remotes_group = gtc() + file_remote_row = gtc() + custom_remote_row = gtc() + none_visible = gtc() + status_stack = gtc() + loading_view = gtc() + adding_view = gtc() + main_view = gtc() + + no_results = gtc() + no_remotes = gtc() + content_page = gtc() + + # Referred to in the main window + # It is used to determine if a new page should be made or not + # This must be set to the created object from within the class's __init__ method + instance = None + page_name = "remotes" + + def start_loading(self): + self.search_button.set_active(False) + self.status_stack.set_visible_child(self.loading_view) + self.total_disabled = 0 + for row in self.current_remote_rows: + self.current_remotes_group.remove(row) + + self.current_remote_rows.clear() + + def end_loading(self): + show_disabled = self.show_disabled_button.get_active() + self.show_disabled_button.set_visible(False) + total_visible = 0 + for installation, remotes in HostInfo.remotes.items(): + for remote in remotes: + row = RemoteRow(self, installation, remote) + self.current_remote_rows.append(row) + self.current_remotes_group.add(row) + if row.remote.disabled: + self.total_disabled += 1 + self.show_disabled_button.set_visible(True) + if show_disabled: + total_visible += 1 + else: + row.set_visible(False) + else: + total_visible += 1 + + self.none_visible.set_visible(total_visible == 0) + + if len(self.current_remote_rows) == 0: + self.no_remotes.set_visible(True) + self.none_visible.set_visible(False) + else: + self.no_remotes.set_visible(False) + + GLib.idle_add(lambda *_: self.status_stack.set_visible_child(self.main_view)) + + def none_visible_handler(self): + any_visible = False + for row in self.current_remote_rows: + if row.get_visible(): + any_visible = True + break + + self.none_visible.set_visible(not any_visible) + + def filter_remote(self, row): + self.filter_setting.set_boolean("show-apps", True) + self.filter_setting.set_boolean("show-runtimes", True) + self.filter_setting.set_string("remotes-list", f"{row.remote.name}<>{row.installation};") + self.filter_setting.reset("runtimes-list") + packages_page = self.main_window.pages[self.main_window.packages_row] + packages_page.filters_page.generate_filters() + packages_page.apply_filters() + GLib.idle_add(lambda *_: self.main_window.activate_row(self.main_window.packages_row)) + GLib.idle_add(lambda *args: packages_page.packages_toast_overlay.add_toast(Adw.Toast(title=_("Showing all packages from {}").format(row.remote.title)))) + + def remove_remote(self, row): + error = [None] + def thread(*args): + install = row.installation + cmd = ['flatpak-spawn', '--host', 'flatpak', 'remote-delete', row.remote.name, '--force'] + if install == "user" or install == "system": + cmd.append(f"--{install}") + else: + cmd.append(f"--installation={install}") + + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as cpe: + error[0] = cpe.stderr + except Exception as e: + error[0] = e + + def callback(*args): + if error[0]: + self.toast_overlay.add_toast(ErrorToast(_("Could not remove remote"), str(error[0])).toast) + else: + filters_page = HostInfo.main_window.pages[HostInfo.main_window.packages_row].filters_page + filters_page.settings.reset("remotes-list") + filters_page.all_remotes_switch.set_active(False) + # filters_page.packages_page.apply_filters() + self.main_window.refresh_handler() + self.toast_overlay.add_toast(Adw.Toast(title=_("Removed {}").format(row.remote.title))) + + def on_response(_, response): + if response != "continue": + return + + Gio.Task.new(None, None, callback).run_in_thread(thread) + + dialog = Adw.AlertDialog(heading=_("Remove {}?").format(row.remote.title), body=_("Any installed apps from {} will stop receiving updates").format(row.remote.name)) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("continue", _("Remove")) + dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.connect("response", on_response) + dialog.present(self.main_window) + + def on_search(self, entry): + text = entry.get_text().lower() + total = 0 + show_disabled = self.show_disabled_button.get_active() + + for row in self.current_remote_rows: + title_match = text in row.get_title().lower() + subtitle_match = text in row.get_subtitle().lower() + visible = (title_match or subtitle_match) and (show_disabled or not row.remote.disabled) + total += visible + row.set_visible(visible) + + if text == "": + self.stack.set_visible_child(self.content_page) + return + + self.stack.set_visible_child(self.content_page if total > 0 else self.no_results) + + def local_file_handler(self, path): + try: + name = path.split("/")[-1].split(".")[0] + info = { + "title": name.title(), + "name": name, + "description": "local file", + "link": path, + } + AddRemoteDialog(self.main_window, self, info).present(self.main_window) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not open file"), str(e)).toast) + + def file_callback(self, chooser, result): + try: + file = chooser.open_finish(result) + path = file.get_path() + self.local_file_handler(path) + except GLib.GError as ge: + if "Dismissed by user" in str(ge): + return + self.toast_overlay.add_toast(ErrorToast(_("Could not open file"), str(ge)).toast) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not open file"), str(e)).toast) + + def add_file_handler(self): + file_filter = Gtk.FileFilter(name=_("Flatpak Repos")) + file_filter.add_suffix("flatpakrepo") + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(file_filter) + file_chooser = Gtk.FileDialog() + file_chooser.set_filters(filters) + file_chooser.set_default_filter(file_filter) + file_chooser.open(self.main_window, None, self.file_callback) + + def show_disabled_handler(self, button): + show_disabled = button.get_active() + self.show_disabled_button_content.set_icon_name("eye-open-negative-filled-symbolic" if show_disabled else "eye-not-looking-symbolic") + total_visible = 0 + for row in self.current_remote_rows: + if row.remote.disabled: + if show_disabled: # show disabled + row.set_visible(True) + total_visible += 1 + else: + row.set_visible(False) + else: + total_visible += 1 + + self.none_visible.set_visible(total_visible == 0) + + def new_custom_handler(self, *args): + AddRemoteDialog(self.main_window, self).present(self.main_window) + + def __init__(self, main_window, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.__class__.instance = self + self.main_window = main_window + self.search_bar.set_key_capture_widget(main_window) + self.current_remote_rows = [] + self.filter_setting = Gio.Settings.new("io.github.flattool.Warehouse.filter") + self.total_disabled = 0 + + # Connections + self.file_remote_row.connect("activated", lambda *_: self.add_file_handler()) + self.custom_remote_row.connect("activated", self.new_custom_handler) + self.search_entry.connect("search-changed", self.on_search) + self.show_disabled_button.connect("toggled", self.show_disabled_handler) + + # Appply + self.adding_view.set_content(LoadingStatus(_("Adding Remote"), _("This should only take a moment"))) + self.loading_view.set_content(LoadingStatus(_("Loading Remotes"), _("This should only take a moment"))) + for item in self.new_remotes: + row = NewRemoteRow(item) + row.connect("activated", lambda *_, remote_info=item: AddRemoteDialog(main_window, self, remote_info).present(main_window)) + self.new_remotes_group.add(row) diff --git a/src/remotes_window.py b/src/remotes_window.py deleted file mode 100644 index 0aa61c0..0000000 --- a/src/remotes_window.py +++ /dev/null @@ -1,710 +0,0 @@ -from gi.repository import Gtk, Adw, GLib, Gdk, Gio -from .common import myUtils -import subprocess -import os -import re - - -@Gtk.Template(resource_path="/io/github/flattool/Warehouse/../data/ui/remotes.ui") -class RemotesWindow(Adw.Dialog): - __gtype_name__ = "RemotesWindow" - - remotes_list = Gtk.Template.Child() - stack = Gtk.Template.Child() - main_group = Gtk.Template.Child() - no_remotes = Gtk.Template.Child() - toast_overlay = Gtk.Template.Child() - popular_remotes_list = Gtk.Template.Child() - add_from_file = Gtk.Template.Child() - custom_remote = Gtk.Template.Child() - refresh = Gtk.Template.Child() - adding = Gtk.Template.Child() - show_disabled_button = Gtk.Template.Child() - show_disabled_button_button_content = Gtk.Template.Child() - show_disabled = False - - is_open = False - rows_in_list = [] - rows_in_popular_list = [] - - def make_toast(self, text): - self.toast_overlay.add_toast(Adw.Toast.new(text)) - - def get_host_flatpaks(self): - output = subprocess.run( - ["flatpak-spawn", "--host", "flatpak", "list", "--columns=all"], - capture_output=True, - text=True, - env=self.new_env, - ).stdout - lines = output.strip().split("\n") - columns = lines[0].split("\t") - data = [columns] - for line in lines[1:]: - row = line.split("\t") - data.append(row) - return data - - def remove_on_response(self, _dialog, response_id, _function, index): - if response_id == "cancel": - return - - name = self.host_remotes[index][0] - title = self.host_remotes[index][1] - install_type = self.host_remotes[index][7] - if "user" in install_type: - install_type = "user" - if "system" in install_type: - install_type = "system" - command = [ - "flatpak-spawn", - "--host", - "flatpak", - "remote-delete", - "--force", - name, - f"--{install_type}", - ] - try: - subprocess.run(command, capture_output=True, check=True, env=self.new_env) - except subprocess.CalledProcessError as e: - self.make_toast(_("Could not remove {}").format(title)) - print("error in remotes_window.remove_on_response: CalledProcessError:", e) - self.generate_list() - - def remove_handler(self, _widget, index, popoever): - popoever.popdown() - name = self.host_remotes[index][0] - title = self.host_remotes[index][1] - install_type = self.host_remotes[index][7] - - body_text = _("Any installed apps from {} will stop receiving updates").format( - name - ) - dialog = Adw.AlertDialog.new(_("Remove {}?").format(title), body_text) - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Remove")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.connect("response", self.remove_on_response, dialog.choose_finish, index) - dialog.present(self) - - def enable_handler(self, button, index): - name = self.host_remotes[index][0] - typeArr = self.host_remotes[index][7] - type = "" - if "system" in typeArr: - type = "system" - else: - type = "user" - - try: - command = [ - "flatpak-spawn", - "--host", - "flatpak", - "remote-modify", - name, - f"--{type}", - "--enable", - ] - subprocess.run(command, capture_output=False, check=True, env=self.new_env) - except subprocess.CalledProcessError as e: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not enable {}").format(name)) - ) - print( - f"error in remotes_window.enable_handler: could not enable remote {name}:", - e, - ) - - self.generate_list() - - def disable_handler(self, button, index, popoever): - def disable_response(_a, response, _b): - if response == "cancel": - return - try: - command = [ - "flatpak-spawn", - "--host", - "flatpak", - "remote-modify", - name, - f"--{type}", - "--disable", - ] - subprocess.run( - command, capture_output=False, check=True, env=self.new_env - ) - except subprocess.CalledProcessError as e: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not disable {}").format(name)) - ) - print( - f"error in remotes_window.enable_handler: could not disable remote {name}:", - e, - ) - - self.generate_list() - - name = self.host_remotes[index][0] - title = self.host_remotes[index][1] - typeArr = self.host_remotes[index][7] - type = "" - if "system" in typeArr: - type = "system" - else: - type = "user" - - popoever.popdown() - - body_text = _("Any installed apps from {} will stop receiving updates").format( - name - ) - dialog = Adw.AlertDialog.new(_("Disable {}?").format(title), body_text) - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Disable")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.connect("response", disable_response, dialog.choose_finish) - dialog.present(self) - - def view_paks(self, type, remote): - if "user" in type: - type = "user" - elif "system" in type: - type = "system" - else: - self.make_toast(_("Could not view apps").format(to_copy)) - print( - "error in remotes_window.view_apps(): remote installation type is not either system or user. type is:", - type, - ) - return - settings = Gio.Settings.new("io.github.flattool.Warehouse.filter") - for key in settings.list_keys(): - settings.reset(key) - settings.set_string("remotes-list", f"{remote}<>{type};") - settings.set_boolean("show-runtimes", True) - self.main_window.apply_filter() - self.close() - - def generate_list(self): - if self.show_disabled_button.get_active(): - self.show_disabled_button_button_content.set_icon_name( - "eye-open-negative-filled-symbolic" - ) - else: - self.show_disabled_button_button_content.set_icon_name( - "eye-not-looking-symbolic" - ) - - self.host_remotes = self.my_utils.get_host_remotes() - self.host_flatpaks = self.get_host_flatpaks() - for i in range(len(self.rows_in_list)): - self.remotes_list.remove(self.rows_in_list[i]) - - self.rows_in_list = [] - - def rowCopyHandler(widget, to_copy): - self.main_window.clipboard.set(to_copy) - self.make_toast(_("Copied {}").format(to_copy)) - - self.no_remotes.set_visible(True) - - has_disabled = False - for i in range(len(self.host_remotes)): - try: - name = self.host_remotes[i][0] - title = self.host_remotes[i][1] - install_type = self.host_remotes[i][7] - remote_row = Adw.ActionRow(title=title) - - more = Gtk.MenuButton( - icon_name="view-more-symbolic", - valign=Gtk.Align.CENTER, - tooltip_text=_("View More"), - ) - more.add_css_class("flat") - options = Gtk.Popover() - options_box = Gtk.Box( - halign=Gtk.Align.CENTER, - valign=Gtk.Align.CENTER, - orientation=Gtk.Orientation.VERTICAL, - ) - - filter_button = Gtk.Button() - filter_button.set_child( - Adw.ButtonContent( - icon_name="funnel-symbolic", label=_("Set Filter") - ) - ) - filter_button.add_css_class("flat") - filter_button.connect( - "clicked", - lambda *_, i=i: self.view_paks( - self.host_remotes[i][7], self.host_remotes[i][0] - ), - ) - - enable_button = Gtk.Button(visible=False) - enable_button.set_child( - Adw.ButtonContent( - icon_name="eye-open-negative-filled-symbolic", label=_("Enable") - ) - ) - enable_button.add_css_class("flat") - enable_button.connect("clicked", self.enable_handler, i) - - disable_button = Gtk.Button() - disable_button.set_child( - Adw.ButtonContent( - icon_name="eye-not-looking-symbolic", label=_("Disable") - ) - ) - disable_button.add_css_class("flat") - disable_button.connect("clicked", self.disable_handler, i, options) - - remove_button = Gtk.Button() - remove_button.set_child( - Adw.ButtonContent( - icon_name="user-trash-symbolic", label=_("Remove") - ) - ) - remove_button.add_css_class("flat") - remove_button.connect("clicked", self.remove_handler, i, options) - - options_box.append(filter_button) - options_box.append(enable_button) - options_box.append(disable_button) - options_box.append(remove_button) - options.set_child(options_box) - more.set_popover(options) - - copy_button = Gtk.Button( - icon_name="edit-copy-symbolic", - valign=Gtk.Align.CENTER, - tooltip_text=_("Copy remote name"), - ) - copy_button.add_css_class("flat") - copy_button.connect("clicked", rowCopyHandler, name) - - remote_row.add_suffix(copy_button) - remote_row.add_suffix(more) - - install_type = self.my_utils.get_install_type(install_type) - if install_type == "disabled": - has_disabled = True - if not self.show_disabled_button.get_active(): - continue - - remote_row.set_subtitle(_("Disabled")) - enable_button.set_visible(True) - disable_button.set_visible(False) - remote_row.add_css_class("warning") - elif install_type == "user": - remote_row.set_subtitle(_("User wide")) - elif install_type == "system": - remote_row.set_subtitle(_("System wide")) - else: - remote_row.set_subtitle(_("Unknown install type")) - - url = self.host_remotes[i][2] - if title == "-": - remote_row.set_title(name) - self.remotes_list.add(remote_row) - # subprocess.run(['wget', f'{self.host_remotes[i][11]}']) Idea to display remote icons... Need internet connection. Not sure if that is worth it - self.rows_in_list.append(remote_row) - self.no_remotes.set_visible(False) - except Exception as e: - print( - "error in remotes_window.generate_list: could not add remote. error:", - e, - ) - self.show_disabled_button.set_visible(has_disabled) - - # Popular remotes - for i in range(len(self.rows_in_popular_list)): - self.popular_remotes_list.remove(self.rows_in_popular_list[i]) - - self.rows_in_popular_list = [] - - remotes = [ - # [Name to show in GUI, Name of remote for system, Link to repo to add, Description of remote] - [ - "AppCenter", - "appcenter", - "https://flatpak.elementary.io/repo.flatpakrepo", - _("The open source, pay-what-you-want app store from elementary"), - ], - [ - "Flathub", - "flathub", - "https://dl.flathub.org/repo/flathub.flatpakrepo", - _("Central repository of Flatpak applications"), - ], - [ - "Flathub beta", - "flathub-beta", - "https://flathub.org/beta-repo/flathub-beta.flatpakrepo", - _("Beta builds of Flatpak applications"), - ], - [ - "Fedora", - "fedora", - "oci+https://registry.fedoraproject.org", - _("Flatpaks packaged by Fedora Linux"), - ], - [ - "GNOME Nightly", - "gnome-nightly", - "https://nightly.gnome.org/gnome-nightly.flatpakrepo", - _("The latest beta GNOME Apps and Runtimes"), - ], - [ - "KDE Testing Applications", - "kdeapps", - "https://distribute.kde.org/kdeapps.flatpakrepo", - _("Beta KDE Apps and Runtimes"), - ], - [ - "WebKit Developer SDK", - "webkit-sdk", - "https://software.igalia.com/flatpak-refs/webkit-sdk.flatpakrepo", - _("Central repository of the WebKit Developer and Runtime SDK"), - ], - ] - - host_remotes = self.my_utils.get_host_remotes() - host_remotes_names = [] - - total_added = 0 - - for i in range(len(self.host_remotes)): - host_remotes_names.append(self.host_remotes[i][0]) - - for i in range(len(remotes)): - if remotes[i][1] in host_remotes_names: - continue - - total_added += 1 - row = Adw.ActionRow( - title=remotes[i][0], subtitle=(remotes[i][2]), activatable=True - ) - row.connect("activated", self.add_handler, remotes[i][1], remotes[i][2]) - row.add_suffix(Gtk.Image.new_from_icon_name("right-large-symbolic")) - self.rows_in_popular_list.append(row) - self.popular_remotes_list.add(row) - - self.popular_remotes_list.set_visible(total_added > 0) - - def addRemoteCallback(self, _a, _b): - self.generate_list() - self.stack.set_visible_child(self.main_group) - - def addRemoteThread(self, command): - try: - subprocess.run(command, capture_output=True, check=True, env=self.new_env) - except subprocess.CalledProcessError as e: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not add {}").format(self.name_to_add)) - ) - print( - "error in remotes_window.addRemoteThread: could not add remote. error:", - e, - ) - - def on_add_response(self, _dialog, response_id, _function, row): - if response_id == "cancel": - self.should_pulse = False - return - - self.stack.set_visible_child(self.adding) - - install_type = "--user" - if not self.add_as_user: - install_type = "--system" - - self.name_to_add = self.name_to_add.strip() - self.url_to_add = self.url_to_add.strip() - - command = [ - "flatpak-spawn", - "--host", - "flatpak", - "remote-add", - "--if-not-exists", - self.name_to_add, - self.url_to_add, - install_type, - ] - task = Gio.Task.new(None, None, self.addRemoteCallback) - task.run_in_thread( - lambda _task, _obj, _data, _cancellable: self.addRemoteThread(command) - ) - - def add_handler(self, row, name="", link=""): - dialog = Adw.AlertDialog.new(_("Add Flatpak Remote")) - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Add")) - dialog.set_response_enabled("continue", False) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.SUGGESTED) - - def name_update(widget): - is_enabled = True - self.name_to_add = widget.get_text() - name_pattern = re.compile(r"^[a-zA-Z0-9\-._]+$") - if not name_pattern.match(self.name_to_add): - is_enabled = False - - if is_enabled: - widget.remove_css_class("error") - else: - widget.add_css_class("error") - - if len(self.name_to_add) == 0: - is_enabled = False - - confirm_enabler(is_enabled) - - def url_update(widget): - is_enabled = True - self.url_to_add = widget.get_text() - url_pattern = re.compile(r"^[a-zA-Z0-9\-._~:/?#[\]@!$&\'()*+,;=]+$") - if not url_pattern.match(self.url_to_add): - is_enabled = False - - if is_enabled: - widget.remove_css_class("error") - else: - widget.add_css_class("error") - - if len(self.url_to_add) == 0: - is_enabled = False - - confirm_enabler(is_enabled) - - def confirm_enabler(is_enabled): - if len(self.name_to_add) == 0 or len(self.url_to_add) == 0: - is_enabled = False - dialog.set_response_enabled("continue", is_enabled) - - def set_user(widget): - self.add_as_user = widget.get_active() - - self.name_to_add = "" - self.url_to_add = "" - self.add_as_user = True - - info_box = Gtk.Box(orientation="vertical") - entry_list = Gtk.ListBox(selection_mode="none", margin_bottom=12) - entry_list.add_css_class("boxed-list") - - name_entry = Adw.EntryRow(title=_("Name")) - name_entry.set_text(name) - name_entry.connect("changed", name_update) - - url_entry = Adw.EntryRow(title=_("URL")) - url_entry.set_text(link) - url_entry.connect("changed", url_update) - - entry_list.append(name_entry) - entry_list.append(url_entry) - info_box.append(entry_list) - - install_type_list = Gtk.ListBox(selection_mode="none") - install_type_list.add_css_class("boxed-list") - - user_row = Adw.ActionRow( - title=_("User"), subtitle=_("Remote will be available to only you") - ) - user_check = Gtk.CheckButton(active=True) - user_check.connect("toggled", set_user) - user_row.add_prefix(user_check) - user_row.set_activatable_widget(user_check) - - system_row = Adw.ActionRow( - title=_("System"), - subtitle=_("Remote will be available to every user on the system"), - ) - system_check = Gtk.CheckButton() - system_row.add_prefix(system_check) - system_check.set_group(user_check) - system_row.set_activatable_widget(system_check) - - install_type_list.append(user_row) - install_type_list.append(system_row) - - info_box.append(install_type_list) - - dialog.set_extra_child(info_box) - dialog.connect("response", self.on_add_response, dialog.choose_finish, row) - dialog.present(self) - - if name != "": - name_update(name_entry) - if link != "": - url_update(url_entry) - - def add_remote_file_thread(self, filepath, system_or_user, name): - try: - subprocess.run( - [ - "flatpak-spawn", - "--host", - "flatpak", - "remote-add", - "--if-not-exists", - name, - filepath, - f"--{system_or_user}", - ], - capture_output=True, - check=True, - env=self.new_env, - ) - self.toast_overlay.add_toast( - Adw.Toast.new(_("{} successfully added").format(name)) - ) - except subprocess.CalledProcessError as e: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not add {}").format(self.name_to_add)) - ) - print( - "error in remotes_window.addRemoteFromFileThread: could not add remote. error:", - e, - ) - - def add_remote_file(self, filepath): - def response(dialog, response, _a): - if response == "cancel": - self.should_pulse = False - return - - user_or_system = "user" - if system_check.get_active(): - user_or_system = "system" - - task = Gio.Task.new(None, None, self.addRemoteCallback) - task.run_in_thread( - lambda *_: self.add_remote_file_thread( - filepath, user_or_system, name_row.get_text() - ) - ) - - def name_update(widget): - is_enabled = True - self.name_to_add = widget.get_text() - name_pattern = re.compile(r"^[a-zA-Z\-]+$") - if not name_pattern.match(self.name_to_add): - is_enabled = False - - if is_enabled: - widget.remove_css_class("error") - else: - widget.add_css_class("error") - - if len(self.name_to_add) == 0: - is_enabled = False - - dialog.set_response_enabled("continue", is_enabled) - - self.should_pulse = True - - name = filepath.split("/") - name = name[len(name) - 1] - - dialog = Adw.AlertDialog.new(self, _("Add {}?").format(name)) - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Add")) - dialog.set_response_enabled("continue", False) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.SUGGESTED) - dialog.connect("response", response, dialog.choose_finish) - - # Create Widgets - options_box = Gtk.Box(orientation="vertical") - options_list = Gtk.ListBox(selection_mode="none", margin_top=15) - name_row = Adw.EntryRow(title=_("Name")) - name_row.connect("changed", name_update) - user_row = Adw.ActionRow( - title=_("User"), subtitle=_("Remote will be available to only you") - ) - system_row = Adw.ActionRow( - title=_("System"), - subtitle=_("Remote will be available to every user on the system"), - ) - user_check = Gtk.CheckButton() - system_check = Gtk.CheckButton() - - # Apply Widgets - user_row.add_prefix(user_check) - user_row.set_activatable_widget(user_check) - system_row.add_prefix(system_check) - system_row.set_activatable_widget(system_check) - user_check.set_group(system_check) - options_list.append(name_row) - options_list.append(user_row) - options_list.append(system_row) - options_box.append(options_list) - dialog.set_extra_child(options_box) - - # Calls - user_check.set_active(True) - options_list.add_css_class("boxed-list") - dialog.present(self) - - def file_callback(self, object, result): - try: - file = object.open_finish(result) - self.add_remote_file(file.get_path()) - except GLib.GError: - pass - - def add_file_handler(self): - filter = Gtk.FileFilter(name=_("Flatpak Repos")) - filter.add_suffix("flatpakrepo") - filters = Gio.ListStore.new(Gtk.FileFilter) - filters.append(filter) - file_chooser = Gtk.FileDialog() - file_chooser.set_filters(filters) - file_chooser.set_default_filter(filter) - file_chooser.open(self.main_window, None, self.file_callback) - - def __init__(self, main_window, **kwargs): - super().__init__(**kwargs) - - # Create Variables - self.my_utils = myUtils(self) - self.host_remotes = [] - self.host_flatpaks = [] - self.main_window = main_window - self.new_env = dict(os.environ) - self.new_env["LC_ALL"] = "C" - self.should_pulse = False - - self.refresh.connect("clicked", lambda *_: self.generate_list()) - - self.add_from_file.add_suffix( - Gtk.Image.new_from_icon_name("right-large-symbolic") - ) - self.add_from_file.connect("activated", lambda *_: self.add_file_handler()) - self.custom_remote.add_suffix( - Gtk.Image.new_from_icon_name("right-large-symbolic") - ) - self.custom_remote.connect("activated", self.add_handler) - self.show_disabled_button.connect("clicked", lambda *_: self.generate_list()) - - # Calls - self.generate_list() - - def set_is_open_false(*args): - self.__class__.is_open = False - self.connect("closed", set_is_open_false) - if self.__class__.is_open: - return - else: - self.present(main_window) - self.__class__.is_open = True \ No newline at end of file diff --git a/src/search_install_window.py b/src/search_install_window.py deleted file mode 100644 index 7fe9983..0000000 --- a/src/search_install_window.py +++ /dev/null @@ -1,266 +0,0 @@ -from gi.repository import Gtk, Adw, GLib, Gdk, Gio -from .common import myUtils -import subprocess -import os -import pathlib - - -class RemoteRow(Adw.ActionRow): - def __init__(self, remote, **kwargs): - super().__init__(**kwargs) - my_utils = myUtils(self) - self.install_type = my_utils.get_install_type(remote[7]) - if self.install_type == "disabled": - self.set_visible(False) - return - self.set_activatable(True) - self.remote = remote - if remote[1] == "-": - self.set_title(remote[0]) - else: - self.set_title(remote[1]) - self.set_subtitle(_("{} wide").format(self.install_type)) - self.add_suffix(Gtk.Image.new_from_icon_name("right-large-symbolic")) - - -class ResultRow(Adw.ActionRow): - def __init__(self, flatpak, **kwargs): - super().__init__(**kwargs) - my_utils = myUtils(self) - name = flatpak[0] - description = flatpak[1] - app_id = flatpak[2] - version = flatpak[3] - self.flatpak = flatpak - self.set_title(GLib.markup_escape_text(f"{name}")) - self.set_subtitle(GLib.markup_escape_text(f"{app_id}\n{description}")) - self.check = Gtk.CheckButton() - self.check.add_css_class("selection-mode") - self.add_suffix( - Gtk.Label( - label=GLib.markup_escape_text(version), - wrap=True, - hexpand=True, - justify=Gtk.Justification.RIGHT, - ) - ) - self.add_suffix(self.check) - self.set_activatable_widget(self.check) - - -@Gtk.Template( - resource_path="/io/github/flattool/Warehouse/../data/ui/search_install.ui" -) -class SearchInstallWindow(Adw.Dialog): - __gtype_name__ = "SearchInstallWindow" - - nav_view = Gtk.Template.Child() - search_page = Gtk.Template.Child() - results_page = Gtk.Template.Child() - remotes_list = Gtk.Template.Child() - search_entry = Gtk.Template.Child() - blank_page = Gtk.Template.Child() - inner_stack = Gtk.Template.Child() - outer_stack = Gtk.Template.Child() - loading_page = Gtk.Template.Child() - results_scroll = Gtk.Template.Child() - results_list = Gtk.Template.Child() - too_many = Gtk.Template.Child() - action_bar = Gtk.Template.Child() - search_button = Gtk.Template.Child() - no_results = Gtk.Template.Child() - install_button = Gtk.Template.Child() - installing = Gtk.Template.Child() - installing_status = Gtk.Template.Child() - search_box = Gtk.Template.Child() - toast_overlay = Gtk.Template.Child() - progress_bar = Gtk.Template.Child() - - is_open = False - - def reset(self): - self.results = [] - self.results_list.remove_all() - self.inner_stack.set_visible_child(self.blank_page) - - def check_handler(self, button, row): - if button.get_active(): - self.selected.append(row.flatpak) - else: - self.selected.remove(row.flatpak) - if len(self.selected) == 0: - self.set_title(self.title) - self.action_bar.set_revealed(False) - else: - self.set_title(_("{} Selected").format(len(self.selected))) - self.action_bar.set_revealed(True) - - def generate_remotes_list(self): - total = 0 - for rem in self.host_remotes: - if self.my_utils.get_install_type(rem[7]) != "disabled": - total += 1 - if total < 2: - self.nav_view.push(self.results_page) - self.results_page.set_can_pop(False) - - for rem in self.host_remotes: - if self.my_utils.get_install_type(rem[7]) != "disabled": - self.search_remote = rem[0] - self.install_type = self.my_utils.get_install_type(rem[7]) - break - - if self.host_remotes[0][1] == "-": - self.title = _("Search {}").format(self.host_remotes[0][0]) - else: - self.title = _("Search {}").format(self.host_remotes[0][1]) - - self.set_title(self.title) - self.search_entry.set_placeholder_text( - _("Search {}").format(self.search_remote) - ) - self.search_entry.grab_focus() - return - - self.nav_view.connect("popped", lambda *_: self.set_title("")) - for remote in self.host_remotes: - row = RemoteRow(remote) - row.connect("activated", self.remote_choice) - self.remotes_list.append(row) - - def generate_results_list(self): - for pak in self.results: - row = ResultRow(pak) - row.check.set_active(row.flatpak in self.selected) - row.check.connect("toggled", self.check_handler, row) - row.set_tooltip_text(row.flatpak[2]) - if self.search_remote in row.flatpak[5].split(","): - self.results_list.append(row) - if self.results_list.get_row_at_index(0): - self.inner_stack.set_visible_child(self.results_scroll) - else: - self.inner_stack.set_visible_child(self.no_results) - - def remote_choice(self, row): - self.reset() - self.selected = [] - self.install_type = row.install_type - self.search_remote = row.remote[0] - self.search_entry.set_placeholder_text( - _("Search {}").format(self.search_remote) - ) - self.title = _("Search {}").format(row.get_title()) - self.set_title(self.title) - self.nav_view.push(self.results_page) - self.search_entry.grab_focus() - self.action_bar.set_revealed(len(self.selected) > 0) - - def search_handler(self, *args): - self.cancel_search.cancel() - self.reset() - self.inner_stack.set_visible_child(self.loading_page) - query = self.search_entry.get_text().strip() - if query == "": - self.inner_stack.set_visible_child(self.blank_page) - return - - def search_thread(*args): - command = [ - "flatpak-spawn", - "--host", - "flatpak", - "search", - "--columns=all", - query, - ] - output = ( - subprocess.run( - command, capture_output=True, text=True, env=self.new_env - ) - .stdout.strip() - .split("\n") - ) - for elm in output: - self.results.append(elm.split("\t")) - - def done(*args): - if len(self.results) > 50: - self.inner_stack.set_visible_child(self.too_many) - return - if ["No matches found"] in self.results: - self.inner_stack.set_visible_child(self.no_results) - return - self.generate_results_list() - - task = Gio.Task.new(None, self.cancel_search, done) - task.run_in_thread(search_thread) - - def install_handler(self, *args): - paks = [] - for pak in self.selected: - paks.append(pak[2]) - self.outer_stack.set_visible_child(self.installing) - self.set_title(_("Install From The Web")) - - def thread(*args): - self.my_utils.install_flatpak( - paks, - self.search_remote, - self.install_type, - self.progress_bar, - self.installing_status, - ) - - def done(*args): - self.main_window.refresh_list_of_flatpaks(None) - # Make window able to close - self.set_can_close(True) - if self.my_utils.install_success: - self.close() - self.main_window.toast_overlay.add_toast( - Adw.Toast.new(_("Installed successfully")) - ) - else: - self.progress_bar.set_visible(False) - self.nav_view.pop() - self.outer_stack.set_visible_child(self.nav_view) - self.toast_overlay.add_toast( - Adw.Toast.new(_("Some apps didn't install")) - ) - - # Make window unable to close - self.set_can_close(False) - task = Gio.Task.new(None, None, done) - task.run_in_thread(thread) - - def __init__(self, main_window, **kwargs): - super().__init__(**kwargs) - - # Create Variables - self.my_utils = myUtils(self) - self.new_env = dict(os.environ) - self.new_env["LC_ALL"] = "C" - self.host_remotes = self.my_utils.get_host_remotes() - self.main_window = main_window - self.results = [] - self.selected = [] - self.search_remote = "" - self.install_type = "" - self.title = _("Install From The Web") - - self.cancel_search = Gio.Cancellable() - self.search_entry.connect("activate", self.search_handler) - self.search_button.connect("clicked", self.search_handler) - self.install_button.connect("clicked", self.install_handler) - - # Apply Widgets - self.generate_remotes_list() - - def set_is_open_false(*args): - self.__class__.is_open = False - self.connect("closed", set_is_open_false) - if self.__class__.is_open: - return - else: - self.present(main_window) - self.__class__.is_open = True \ No newline at end of file diff --git a/src/snapshot_page/new_snapshot_dialog.blp b/src/snapshot_page/new_snapshot_dialog.blp new file mode 100644 index 0000000..31f0eb5 --- /dev/null +++ b/src/snapshot_page/new_snapshot_dialog.blp @@ -0,0 +1,94 @@ +using Gtk 4.0; +using Adw 1; + +template $NewSnapshotDialog : Adw.Dialog { + follows-content-size: true; + Adw.ToastOverlay toast_overlay { + Adw.NavigationPage nav_page { + title: "No Title Set"; + Adw.ToolbarView { + [top] + Adw.HeaderBar { + show-start-title-buttons: false; + show-end-title-buttons: false; + [start] + Button list_cancel_button { + label: _("Cancel"); + } + [start] + ToggleButton search_button { + icon-name: "loupe-large-symbolic"; + tooltip-text: _("Search Apps"); + } + [end] + Button create_button { + sensitive: false; + label: _("Create"); + styles ["suggested-action"] + } + } + [top] + Adw.Clamp { + SearchBar search_bar { + search-mode-enabled: bind search_button.active bidirectional; + key-capture-widget: template; + SearchEntry search_entry { + hexpand: true; + placeholder-text: _("Search Apps"); + } + } + } + Stack stack { + ScrolledWindow scrolled_window { + propagate-natural-height: true; + propagate-natural-width: true; + Box { + orientation: vertical; + Adw.EntryRow name_entry { + title: "No Title Set"; + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + styles ["card"] + } + ListBox listbox { + valign: start; + margin-start: 12; + margin-end: 12; + // margin-top: 12; + margin-bottom: 12; + selection-mode: none; + styles ["boxed-list"] + } + } + } + Adw.StatusPage no_results { + title: _("No Results Found"); + description: _("Try a different search"); + icon-name: "system-search-symbolic"; + } + } + [bottom] + ActionBar { + revealed: bind search_button.visible; + [start] + Button select_all_button { + styles ["raised"] + Adw.ButtonContent { + label: _("Select All"); + icon-name: "selection-mode-symbolic"; + } + } + [end] + Label total_selected_label { + label: ""; + ellipsize: middle; + margin-end: 6; + visible: false; + } + } + } + } + } +} diff --git a/src/snapshot_page/new_snapshot_dialog.py b/src/snapshot_page/new_snapshot_dialog.py new file mode 100644 index 0000000..0be4539 --- /dev/null +++ b/src/snapshot_page/new_snapshot_dialog.py @@ -0,0 +1,204 @@ +from gi.repository import Adw, Gtk, GLib +from .host_info import HostInfo +from .error_toast import ErrorToast +from .loading_status import LoadingStatus +from .app_row import AppRow +from .tar_worker import TarWorker +import os, time + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/snapshot_page/new_snapshot_dialog.ui") +class NewSnapshotDialog(Adw.Dialog): + __gtype_name__ = "NewSnapshotDialog" + gtc = Gtk.Template.Child + + toast_overlay = gtc() + nav_page = gtc() + list_cancel_button = gtc() + search_button = gtc() + create_button = gtc() + search_entry = gtc() + name_entry = gtc() + listbox = gtc() + select_all_button = gtc() + total_selected_label = gtc() + scrolled_window = gtc() + no_results = gtc() + stack = gtc() + is_open = False + + def row_gesture_handler(self, row): + row.check_button.set_active(not row.check_button.get_active()) + + def row_select_handler(self, row): + if row.check_button.get_active(): + self.selected_rows.append(row) + else: + self.selected_rows.remove(row) + + total = len(self.selected_rows) + self.total_selected_label.set_label(_("{} Selected").format(total)) + self.total_selected_label.set_visible(total > 0) + self.valid_checker() + + def generate_list(self, *args): + for package in HostInfo.flatpaks: + if "io.github.flattool.Warehouse" in package.info["id"]: + continue + + if package.is_runtime or not os.path.exists(package.data_path): + continue + + row = AppRow(package, self.row_gesture_handler) + row.check_button.set_visible(True) + row.check_button.connect("toggled", lambda *_, row=row: self.row_select_handler(row)) + row.set_activatable(True) + row.set_activatable_widget(row.check_button) + self.listbox.append(row) + + def sort_func(self, row1, row2): + return row1.package.info["name"].lower() > row2.package.info["name"].lower() + + def filter_func(self, row): + title = row.get_title().lower() + subtitle = row.get_subtitle().lower() + search = self.search_entry.get_text().lower() + if search in title or search in subtitle: + self.is_result = True + return True + else: + return False + + def on_close(self, *args): + self.__class__.is_open = False + self.search_button.set_active(False) + for row in self.selected_rows.copy(): + GLib.idle_add(lambda *_, row=row: row.check_button.set_active(False)) + + def valid_checker(self): + text = self.name_entry.get_text().strip() + something_selected = len(self.selected_rows) > 0 + text_good = len(text) > 0 and not("/" in text or "\0" in text) + self.create_button.set_sensitive(something_selected and text_good) + if text_good: + self.name_entry.remove_css_class("error") + else: + self.name_entry.add_css_class("error") + + return something_selected and text_good + + def get_total_fraction(self): + total = 0 + stopped_workers_amount = 0 + for worker in self.workers: + total += worker.fraction + if worker.stop: + stopped_workers_amount += 1 + + if stopped_workers_amount == len(self.workers): + self.loading_status.progress_bar.set_fraction(1) + self.loading_status.progress_label.set_label(f"{len(self.workers)} / {len(self.workers)}") + self.workers.clear() + if self.on_done: + self.on_done() + + return False + + self.loading_status.progress_label.set_label(f"{stopped_workers_amount + 1} / {len(self.workers)}") + self.loading_status.progress_bar.set_fraction(total / len(self.workers)) + return True + + def on_create(self, button): + self.loading_status.title_label.set_label(_("Creating Snapshot")) + self.loading_status.progress_bar.set_fraction(0.0) + self.snapshot_page.status_stack.set_visible_child(self.snapshot_page.snapshotting_view) + self.workers.clear() + for row in self.selected_rows: + if "io.github.flattool.Warehouse" in row.package.info["id"]: + continue + + package = row.package + worker = TarWorker( + existing_path=package.data_path, + new_path=f"{HostInfo.snapshots_path}{package.info['id']}", + file_name=f"{int(time.time())}_{package.info["version"]}", + name=self.name_entry.get_text(), + toast_overlay=self.snapshot_page.toast_overlay, + ) + self.workers.append(worker) + worker.compress() + + self.loading_status.progress_label.set_visible(len(self.workers) > 1) + GLib.timeout_add(200, self.get_total_fraction) + self.close() + + def on_invalidate(self, search_entry): + self.is_result = False + self.listbox.invalidate_filter() + if self.is_result: + self.stack.set_visible_child(self.scrolled_window) + else: + self.stack.set_visible_child(self.no_results) + + def on_select_all(self, button): + i = 0 + while row := self.listbox.get_row_at_index(i): + i += 1 + row.check_button.set_active(True) + + def set_packages(self): + for package in self.packages: + row = AppRow(package) + row.set_activatable(False) + self.selected_rows.append(row) + self.listbox.append(row) + + def enter_handler(self, *args): + if self.create_button.get_sensitive(): + self.create_button.activate() + + def present(self, *args, **kwargs): + if self.__class__.is_open: + return + + super().present(*args, **kwargs) + self.__class__.is_open = True + if not self.search_button.get_visible(): + self.name_entry.grab_focus() + + def __init__(self, snapshot_page, loading_status, on_done=None, packages=None, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creations + self.snapshot_page = snapshot_page + self.loading_status = loading_status + self.on_done = on_done + self.is_result = False + self.rows = [] + self.selected_rows = [] + self.workers = [] + self.packages = packages + + # Connections + self.connect("closed", self.on_close) + self.create_button.connect("clicked", self.on_create) + self.search_entry.connect("search-changed", self.on_invalidate) + self.list_cancel_button.connect("clicked", lambda *_: self.close()) + self.name_entry.connect("changed", lambda *_: self.valid_checker()) + self.name_entry.connect("entry-activated", self.enter_handler) + self.select_all_button.connect("clicked", self.on_select_all) + + # Apply + self.listbox.set_sort_func(self.sort_func) + self.listbox.set_filter_func(self.filter_func) + self.name_entry.set_title(_("Name these Snapshots")) + if not packages is None: + self.search_entry.set_editable(False) + self.search_button.set_visible(False) + self.nav_page.set_title(_("New Snapshot")) + self.set_packages() + self.no_results.set_visible(False) + if len(packages) == 1: + self.name_entry.set_title(_("Name this Snapshot")) + else: + self.nav_page.set_title(_("New Snapshots")) + self.generate_list() diff --git a/src/snapshot_page/snapshot_box.blp b/src/snapshot_page/snapshot_box.blp new file mode 100644 index 0000000..53db485 --- /dev/null +++ b/src/snapshot_page/snapshot_box.blp @@ -0,0 +1,99 @@ +using Gtk 4.0; +using Adw 1; + +template $SnapshotBox : Gtk.Box { + orientation: vertical; + spacing: 6; + Box { + margin-start: 12; + margin-end: 12; + margin-top: 6; + spacing: 12; + Box { + orientation: vertical; + Label title { + label: _("No Name Set"); + wrap: true; + wrap-mode: word_char; + justify: left; + halign: start; + styles ["title-4"] + } + Label date { + label: _("No date found"); + wrap: true; + justify: left; + halign: start; + } + } + Label version { + label: _("No version found"); + wrap: true; + justify: right; + hexpand: true; + halign: end; + natural-wrap-mode: none; + } + } + Box { + margin-start: 6; + margin-end: 6; + margin-bottom: 6; + spacing: 3; + homogeneous: true; + Button apply_button { + Adw.ButtonContent { + label: _("Apply"); + icon-name: "check-plain-symbolic"; + can-shrink: true; + } + hexpand: true; + styles ["flat"] + } + MenuButton rename_button { + Adw.ButtonContent { + label: _("Rename"); + icon-name: "edit-symbolic"; + can-shrink: true; + } + hexpand: true; + styles ["flat"] + popover: rename_menu; + } + Button trash_button { + Adw.ButtonContent { + label: _("Trash"); + icon-name: "user-trash-symbolic"; + can-shrink: true; + } + hexpand: true; + styles ["flat"] + } + } +} + +Popover rename_menu { + Box { + orientation: vertical; + spacing: 11; + margin-start: 12; + margin-end: 12; + margin-top: 5; + margin-bottom: 12; + Label { + label: _("Rename Snapshot?"); + styles ["title-2"] + } + Box { + spacing: 6; + Entry rename_entry { + text: bind title.label; + } + Button apply_rename { + icon-name: "check-plain-symbolic"; + tooltip-text: _("Confirm Rename"); + styles ["circular", "suggested-action"] + } + } + } +} diff --git a/src/snapshot_page/snapshot_box.py b/src/snapshot_page/snapshot_box.py new file mode 100644 index 0000000..08879bb --- /dev/null +++ b/src/snapshot_page/snapshot_box.py @@ -0,0 +1,189 @@ +from gi.repository import Adw, Gtk, GLib, Gio +from .host_info import HostInfo +from .error_toast import ErrorToast +from .tar_worker import TarWorker +import os, subprocess, json, re + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/snapshot_page/snapshot_box.ui") +class SnapshotBox(Gtk.Box): + __gtype_name__ = "SnapshotBox" + gtc = Gtk.Template.Child + + title = gtc() + date = gtc() + version = gtc() + apply_button = gtc() + rename_button = gtc() + rename_menu = gtc() + rename_entry = gtc() + apply_rename = gtc() + trash_button = gtc() + + def create_json(self): + try: + data = { + 'snapshot_version': 1, + 'name': '', + } + with open(self.json_path, 'w') as file: + json.dump(data, file, indent=4) + return None + + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not write data"), str(e)).toast) + + def update_json(self, key, value): + try: + with open(self.json_path, 'r+') as file: + data = json.load(file) + data[key] = value + file.seek(0) + json.dump(data, file, indent=4) + file.truncate() + + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not write data"), str(e)).toast) + + def load_from_json(self): + if not os.path.exists(self.json_path): + self.create_json() + + try: + with open(self.json_path, 'r') as file: + data = json.load(file) + name = data['name'] + if name != "": + self.title.set_label(GLib.markup_escape_text(name)) + else: + self.title.set_label(_("No Name Set")) + + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not write data"), str(e)).toast) + + def on_rename(self, widget): + if not self.valid_checker(): + return + + self.update_json('name', self.rename_entry.get_text().strip()) + self.load_from_json() + self.rename_menu.popdown() + + def valid_checker(self, *args): + text = self.rename_entry.get_text().strip() + valid = not ("/" in text or "\0" in text) and len(text) > 0 + self.apply_rename.set_sensitive(valid) + if valid: + self.rename_entry.remove_css_class("error") + else: + self.rename_entry.add_css_class("error") + + return valid + + def on_trash(self, button): + error = [None] + path = f"{self.snapshots_path}{self.folder}" + if self.snapshot_page.is_trash_dialog_open: + return + + def thread(*args): + try: + subprocess.run(['gio', 'trash', path], capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as cpe: + error[0] = cpe.stderr + except Exception as e: + error[0] = str(e) + + def callback(*args): + if not error[0] is None: + self.toast_overlay.add_toast(ErrorToast(_("Could not trash snapshot"), error[0]).toast) + return + + self.parent_page.on_trash() + self.toast_overlay.add_toast(Adw.Toast.new(_("Trashed snapshot"))) + + def on_response(_, response): + self.snapshot_page.is_trash_dialog_open = False + if response != "continue": + return + + Gio.Task.new(None, None, callback).run_in_thread(thread) + + self.snapshot_page.is_trash_dialog_open = True + dialog = Adw.AlertDialog(heading=_("Trash Snapshot?"), body=_("This snapshot will be sent to the trash")) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("continue", _("Trash")) + dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.connect("response", on_response) + dialog.present(HostInfo.main_window) + + def get_fraction(self): + loading_status = self.snapshot_page.snapshotting_status + loading_status.progress_bar.set_fraction(self.worker.fraction) + if self.worker.stop: + self.snapshot_page.status_stack.set_visible_child(self.snapshot_page.split_view) + self.parent_page.set_snapshots(self.parent_page.package_or_folder, True) + properties_page = HostInfo.main_window.pages[HostInfo.main_window.packages_row].properties_page + properties_page.set_properties(properties_page.package, True) + data_page = HostInfo.main_window.pages[HostInfo.main_window.user_data_row] + data_page.start_loading() + data_page.end_loading() + if self.worker in self.snapshot_page.workers: + self.snapshot_page.workers.remove(self.worker) + + return False # Stop the timeout + else: + return True # Continue the timeout + + def on_apply(self, button): + def on_response(dialog, response): + if response != "continue": + return + + self.snapshot_page.snapshotting_status.title_label.set_label(_("Applying Snapshot")) + self.snapshot_page.snapshotting_status.progress_label.set_visible(False) + self.snapshot_page.snapshotting_status.progress_bar.set_fraction(0.0) + self.snapshot_page.status_stack.set_visible_child(self.snapshot_page.snapshotting_view) + self.snapshot_page.workers.append(self.worker) + self.worker.extract() + GLib.timeout_add(200, self.get_fraction) + + has_data = os.path.exists(self.worker.new_path) + dialog = Adw.AlertDialog( + heading=_("Apply Snapshot?"), + body=_("Any current user data for this app will be trashed") if has_data else "", + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("continue", _("Apply")) + dialog.connect("response", on_response) + dialog.present(HostInfo.main_window) + + def __init__(self, parent_page, folder, snapshots_path, toast_overlay, **kwargs): + super().__init__(**kwargs) + + self.snapshot_page = parent_page.parent_page + self.toast_overlay = toast_overlay + self.app_id = snapshots_path.split('/')[-2].strip() + self.worker = TarWorker( + existing_path=f"{snapshots_path}{folder}", + new_path=f"{HostInfo.home}/.var/app/{self.app_id}/", + toast_overlay=self.toast_overlay, + ) + + split_folder = folder.split('_') + if len(split_folder) < 2: + return + + self.parent_page = parent_page + self.folder = folder + self.snapshots_path = snapshots_path + self.epoch = int(split_folder[0]) + date_data = GLib.DateTime.new_from_unix_local(self.epoch).format("%x %X") + self.date.set_label(date_data) + self.version.set_label(_("Version: {}").format(split_folder[1].replace(".tar.zst", ""))) + self.json_path = f"{snapshots_path}{folder.replace('tar.zst', 'json')}" + self.load_from_json() + self.apply_button.connect("clicked", self.on_apply) + self.apply_rename.connect("clicked", self.on_rename) + self.rename_entry.connect("activate", self.on_rename) + self.rename_entry.connect("changed", self.valid_checker) + self.trash_button.connect("clicked", self.on_trash) diff --git a/src/snapshot_page/snapshot_page.blp b/src/snapshot_page/snapshot_page.blp new file mode 100644 index 0000000..1963a8e --- /dev/null +++ b/src/snapshot_page/snapshot_page.blp @@ -0,0 +1,235 @@ +using Gtk 4.0; +using Adw 1; + +template $SnapshotPage : Adw.BreakpointBin { + width-request: 1; + height-request: 1; + + Adw.Breakpoint bp1 { + condition ("max-width: 600") + + setters { + split_view.collapsed: true; + split_view.show-content: false; + } + } + + Adw.NavigationPage { + title: _("Snapshots"); + Adw.ToastOverlay toast_overlay { + Stack status_stack { + Adw.NavigationSplitView split_view { + sidebar-width-fraction: 0.5; + max-sidebar-width: 999999999; + sidebar: + Adw.NavigationPage sidebar_navpage { + title: _("Snapshots"); + Adw.ToolbarView sidebar_tbv { + [top] + Adw.HeaderBar header_bar { + [start] + $SidebarButton {} + [start] + ToggleButton search_button { + icon-name: "loupe-large-symbolic"; + tooltip-text: _("Search Packages"); + } + [end] + Button new_button { + icon-name: "plus-large-symbolic"; + tooltip-text: _("New Snapshot"); + } + [end] + ToggleButton select_button { + icon-name: "selection-mode-symbolic"; + tooltip-text: _("Select Packages"); + } + } + [top] + SearchBar search_bar { + search-mode-enabled: bind search_button.active bidirectional; + SearchEntry search_entry { + hexpand: true; + placeholder-text: _("Search Snapshots"); + } + } + Stack stack { + Adw.StatusPage no_results { + title: _("No Results Found"); + description: _("Try a different search"); + icon-name: "system-search-symbolic"; + } + ScrolledWindow scrolled_window { + Box { + orientation: vertical; + + Box active_box { + orientation: vertical; + + Label { + label: _("Active Snapshots"); + halign: start; + styles ["heading"] + margin-top: 3; + margin-bottom: 6; + margin-start: 12; + margin-end: 12; + wrap: true; + wrap-mode: word_char; + } + Label { + label: _("Snapshots of installed apps"); + halign: start; + styles ["dim-label"] + margin-start: 12; + margin-end: 12; + margin-bottom: 3; + wrap: true; + wrap-mode: word_char; + } + ListBox active_listbox { + styles ["navigation-sidebar"] + valign: start; + } + } + Box leftover_box { + orientation: vertical; + + Label { + label: _("Leftover Snapshots"); + halign: start; + styles ["heading"] + margin-top: 3; + margin-bottom: 6; + margin-start: 12; + margin-end: 12; + wrap: true; + wrap-mode: word_char; + } + Label { + label: _("Snapshots of apps that are no longer installed"); + halign: start; + styles ["dim-label"] + margin-start: 12; + margin-end: 12; + margin-bottom: 3; + wrap: true; + wrap-mode: word_char; + } + ListBox leftover_listbox { + styles ["navigation-sidebar"] + valign: start; + } + } + } + } + } + [bottom] + Revealer { + reveal-child: bind select_button.active; + transition-type: slide_up; + [center] + Box bottom_bar { + styles ["toolbar"] + hexpand: true; + homogeneous: true; + Button select_all_button { + styles ["raised"] + Adw.ButtonContent { + icon-name: "selection-mode-symbolic"; + label: _("Select All"); + can-shrink: true; + } + } + Button copy_button { + sensitive: false; + styles ["raised"] + Adw.ButtonContent { + icon-name: "edit-copy-symbolic"; + label: _("Copy"); + can-shrink: true; + } + } + MenuButton more_button { + sensitive: false; + popover: more_popover; + styles ["raised"] + Adw.ButtonContent { + icon-name: "view-more-symbolic"; + label: _("More"); + can-shrink: true; + } + } + } + } + } + } + ; + } + Adw.ToolbarView no_snapshots { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + [start] + Button status_open_button { + icon-name: "folder-open-symbolic"; + tooltip-text: _("Open Snapshots Folder"); + } + } + Adw.ToastOverlay no_snapshots_toast { + Adw.StatusPage { + title: _("No Snapshots"); + description: _("Create a Snapshot to save the state of any Flatpak application"); + icon-name: "snapshots-alt-symbolic"; + Button status_new_button { + styles ["suggested-action", "pill"] + halign: center; + Adw.ButtonContent { + icon-name: "plus-large-symbolic"; + label: _("New Snapshot"); + } + } + } + } + } + Adw.ToolbarView loading_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + Adw.ToolbarView snapshotting_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + } + } + } +} + +Popover more_popover { + styles ["menu"] + ListBox more_menu { + Label new_snapshots { + label: _("Snapshot Apps"); + halign: start; + } + Label install_from_snapshots { + label: _("Install Apps"); + halign: start; + } + Label apply_snapshots { + label: _("Apply Snapshots"); + halign: start; + } + Label trash_snapshots { + label: _("Trash Snapshots"); + halign: start; + } + } +} diff --git a/src/snapshot_page/snapshot_page.py b/src/snapshot_page/snapshot_page.py new file mode 100644 index 0000000..aad1d05 --- /dev/null +++ b/src/snapshot_page/snapshot_page.py @@ -0,0 +1,562 @@ +from gi.repository import Adw, Gtk, GLib, Gio, Gdk +from .host_info import HostInfo +from .error_toast import ErrorToast +from .app_row import AppRow +from .snapshots_list_page import SnapshotsListPage +from .sidebar_button import SidebarButton +from .loading_status import LoadingStatus +from .new_snapshot_dialog import NewSnapshotDialog +from .tar_worker import TarWorker +from .attempt_install_dialog import AttemptInstallDialog +import os, subprocess + +class LeftoverSnapshotRow(Adw.ActionRow): + __gtype_name__ = "LeftoverSnapshotRow" + + def idle_stuff(self): + self.set_title(self.name) + icon = Gtk.Image.new_from_icon_name("application-x-executable-symbolic") + icon.set_icon_size(Gtk.IconSize.LARGE) + self.add_prefix(icon) + self.add_suffix(self.check_button) + + def gesture_handler(self, *args): + self.on_long_press(self) + + def __init__(self, folder, on_long_press, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.folder = folder + self.check_button = Gtk.CheckButton(visible=False) + self.on_long_press = on_long_press + self.rclick_gesture = Gtk.GestureClick(button=3) + self.long_press_gesture = Gtk.GestureLongPress() + + # Apply + self.add_controller(self.rclick_gesture) + self.add_controller(self.long_press_gesture) + self.check_button.add_css_class("selection-mode") + self.name = self.folder.split('.')[-1] + self.set_activatable(True) + GLib.idle_add(lambda *_: self.idle_stuff()) + + # Connections + self.rclick_gesture.connect("released", self.gesture_handler) + self.long_press_gesture.connect("pressed", self.gesture_handler) + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/snapshot_page/snapshot_page.ui") +class SnapshotPage(Adw.BreakpointBin): + __gtype_name__ = "SnapshotPage" + gtc = Gtk.Template.Child + + toast_overlay = gtc() + sidebar_navpage = gtc() + search_button = gtc() + select_button = gtc() + search_entry = gtc() + search_bar = gtc() + active_box = gtc() + active_listbox = gtc() + leftover_box = gtc() + leftover_listbox = gtc() + split_view = gtc() + stack = gtc() + no_snapshots = gtc() + no_results = gtc() + scrolled_window = gtc() + status_open_button = gtc() + status_new_button = gtc() + new_button = gtc() + status_stack = gtc() + loading_view = gtc() + snapshotting_view = gtc() + select_all_button = gtc() + copy_button = gtc() + more_button = gtc() + more_popover = gtc() + more_menu = gtc() + new_snapshots = gtc() + apply_snapshots = gtc() + install_from_snapshots = gtc() + trash_snapshots = gtc() + + # Referred to in the main window + # It is used to determine if a new page should be made or not + # This must be set to the created object from within the class's __init__ method + instance = None + page_name = "snapshots" + is_trash_dialog_open = False + + def sort_snapshots(self, *args): + self.active_snapshot_paks.clear() + self.leftover_snapshots.clear() + bad_folders = [] + + if not os.path.exists(HostInfo.snapshots_path): + try: + os.makedirs(HostInfo.snapshots_path) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not load Snapshots"), str(e)).toast) + return + + for folder in os.listdir(HostInfo.snapshots_path): + if folder.count('.') < 2 or ' ' in folder: + bad_folders.append(folder) + continue + + has_tar = False + for file in os.listdir(f"{HostInfo.snapshots_path}{folder}"): + if file.endswith(".tar.zst"): + has_tar = True + break + + if not has_tar: + bad_folders.append(folder) + continue + + try: + pak = HostInfo.id_to_flatpak[folder] + self.active_snapshot_paks.append(pak) + except KeyError: + self.leftover_snapshots.append(folder) + + for folder in bad_folders: + try: + subprocess.run(['gio', 'trash', f'{HostInfo.snapshots_path}{folder}']) + except Exception: + pass + + def long_press_handler(self, row): + self.select_button.set_active(True) + row.check_button.set_active(not row.check_button.get_active()) + + def generate_active_list(self): + for pak in self.active_snapshot_paks: + row = AppRow(pak, self.long_press_handler) + row.check_button.connect("toggled", lambda *_, _row=row: self.row_select_handler(_row)) + self.active_listbox.append(row) + + if len(self.active_snapshot_paks) > 0: + self.active_box.set_visible(True) + # first_row = self.active_listbox.get_row_at_index(0) + # self.active_listbox.select_row(first_row) + else: + self.active_box.set_visible(False) + + def generate_leftover_list(self): + for folder in self.leftover_snapshots: + row = LeftoverSnapshotRow(folder, self.long_press_handler) + row.check_button.connect("toggled", lambda *_, _row=row: self.row_select_handler(_row)) + self.leftover_listbox.append(row) + + if len(self.leftover_snapshots) > 0: + self.leftover_box.set_visible(True) + if len(self.active_snapshot_paks) == 0: + self.stack.set_visible_child(self.scrolled_window) + # first_row = self.leftover_listbox.get_row_at_index(0) + # self.leftover_listbox.select_row(first_row) + else: + self.leftover_box.set_visible(False) + + def active_select_handler(self, listbox, row, should_show_content=True, refresh=False): + self.leftover_listbox.select_row(None) + self.list_page.set_snapshots(row.package, refresh) + self.split_view.set_show_content(should_show_content) + + def leftover_select_handler(self, listbox, row, should_show_content=True, refresh=False): + self.active_listbox.select_row(None) + self.list_page.set_snapshots(row.folder, refresh) + self.split_view.set_show_content(should_show_content) + + def select_first_row(self): + if row := self.active_listbox.get_row_at_index(0): + self.active_listbox.select_row(row) + self.active_select_handler(None, row, False, True) + elif row := self.leftover_listbox.get_row_at_index(0): + self.leftover_listbox.select_row(row) + self.leftover_select_handler(None, row, False, True) + + def show_snapshot(self, package): + i = 0 + while row := self.active_listbox.get_row_at_index(i): + i += 1 + if row.package is package: + self.active_listbox.select_row(row) + self.active_select_handler(None, row, True) + self.toast_overlay.add_toast(Adw.Toast(title=_("Showing snapshots for {}").format(package.info['name']))) + break + else: + dialog = NewSnapshotDialog(self, self.snapshotting_status, self.refresh, [package]) + toast = Adw.Toast(title=_("No snapshots for {}").format(package.info['name']), button_label=_("New")) + toast.connect("button-clicked", lambda *_: dialog.present(HostInfo.main_window)) + self.toast_overlay.add_toast(toast) + + def start_loading(self): + self.search_button.set_active(False) + self.workers.clear() + self.select_button.set_active(False) + self.status_stack.set_visible_child(self.loading_view) + self.active_box.set_visible(True) + self.active_listbox.remove_all() + self.leftover_box.set_visible(True) + self.leftover_listbox.remove_all() + self.selected_active_rows.clear() + self.selected_leftover_rows.clear() + + def end_loading(self): + def callback(*args): + self.generate_active_list() + self.generate_leftover_list() + if (not self.active_box.get_visible()) and (not self.leftover_box.get_visible()): + GLib.idle_add(lambda *_: self.status_stack.set_visible_child(self.no_snapshots)) + else: + self.select_first_row() + GLib.idle_add(lambda *_: self.stack.set_visible_child(self.scrolled_window)) + GLib.idle_add(lambda *_: self.status_stack.set_visible_child(self.split_view)) + + data_path = f"{HostInfo.home}/.var/app" + data_exists = False + for package in HostInfo.flatpaks: + if package.info['id'] == "io.github.flattool.Warehouse": + continue + + if os.path.exists(package.data_path): + data_exists = True + break + + if data_exists: + self.new_button.set_sensitive(True) + self.new_button.set_tooltip_text(None) + self.status_new_button.set_sensitive(True) + self.status_new_button.set_tooltip_text(None) + else: + self.new_button.set_sensitive(False) + self.new_button.set_tooltip_text(_("No Data Found to Snapshot")) + self.status_new_button.set_sensitive(False) + self.status_new_button.set_tooltip_text(_("No Data Found to Snapshot")) + + Gio.Task.new(None, None, callback).run_in_thread(self.sort_snapshots) + + def open_snapshots_folder(self, button): + try: + Gio.AppInfo.launch_default_for_uri(f"file://{HostInfo.snapshots_path}", None) + self.toast_overlay.add_toast(Adw.Toast.new(_("Opened snapshots folder"))) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not open folder"), str(e)).toast) + + def on_cancel(self): + for worker in self.workers: + worker.do_cancel("manual_cancel") + + if self.new_snapshot_dialog is None: + return + + for worker in self.new_snapshot_dialog.workers: + worker.do_cancel("manual_cancel") + + def on_new(self, *args): + self.new_snapshot_dialog = NewSnapshotDialog(self, self.snapshotting_status, self.refresh) + self.new_snapshot_dialog.present(HostInfo.main_window) + + def refresh(self): + self.start_loading() + self.end_loading() + + def on_search(self, search_entry): + text = search_entry.get_text().lower() + i = 0 + total_active_visible = 0 + while row := self.active_listbox.get_row_at_index(i): + i += 1 + row.set_visible(False) + if text in row.get_title().lower(): + row.set_visible(True) + total_active_visible += 1 + self.active_box.set_visible(total_active_visible > 0) + + i = 0 + total_leftover_visible = 0 + while row := self.leftover_listbox.get_row_at_index(i): + i += 1 + row.set_visible(False) + if text in row.get_title().lower(): + row.set_visible(True) + total_leftover_visible += 1 + self.leftover_box.set_visible(total_leftover_visible > 0) + + if total_active_visible > 0 or total_leftover_visible > 0: + self.stack.set_visible_child(self.scrolled_window) + else: + self.stack.set_visible_child(self.no_results) + + def sort_func(self, row1, row2): + if type(row1) is AppRow: + return row1.package.info['name'].lower() > row2.package.info['name'].lower() + else: + return row1.name.lower() > row2.name.lower() + + def set_selection_mode(self, *args): + enable = self.select_button.get_active() + i = 0 + while row := self.active_listbox.get_row_at_index(i): + i += 1 + row.check_button.set_visible(enable) + if not enable: + row.check_button.set_active(False) + + i = 0 + while row := self.leftover_listbox.get_row_at_index(i): + i += 1 + row.check_button.set_visible(enable) + if not enable: + row.check_button.set_active(False) + + def select_all_handler(self, *args): + i = 0 + while row := self.active_listbox.get_row_at_index(i): + i += 1 + row.check_button.set_active(True) + + i = 0 + while row := self.leftover_listbox.get_row_at_index(i): + i += 1 + row.check_button.set_active(True) + + def row_select_handler(self, row): + if type(row) is AppRow: + if row.check_button.get_active(): + self.selected_active_rows.append(row) + elif row in self.selected_active_rows: + self.selected_active_rows.remove(row) + elif type(row) is LeftoverSnapshotRow: + if row.check_button.get_active(): + self.selected_leftover_rows.append(row) + elif row in self.selected_leftover_rows: + self.selected_leftover_rows.remove(row) + + total_active = len(self.selected_active_rows) + total_leftover = len(self.selected_leftover_rows) + total = total_active + total_leftover + self.sidebar_navpage.set_title(_("{} Selected").format(total_active + total_leftover) if total > 0 else _("Snapshots")) + self.new_snapshots.set_visible(total_active > 0) + self.copy_button.set_sensitive(total > 0) + self.more_button.set_sensitive(total > 0) + i = 0 + while row := self.more_menu.get_row_at_index(i): + i += 1 + match row.get_child(): + case self.new_snapshots: + row.set_visible(total_active > 0 and total_leftover == 0) + case self.apply_snapshots: + row.set_visible(total_active > 0 and total_leftover == 0) + case self.install_from_snapshots: + row.set_visible(total_active == 0 and total_leftover > 0) + + def select_copy_handler(self, *args): + to_copy = "" + i = 0 + while row := self.active_listbox.get_row_at_index(i): + i += 1 + if row.check_button.get_active(): + to_copy += f"{HostInfo.snapshots_path}{row.package.info['id']}\n" + + i = 0 + while row := self.leftover_listbox.get_row_at_index(i): + i += 1 + if row.check_button.get_active(): + to_copy += f"{HostInfo.snapshots_path}{row.folder}\n" + + to_copy = to_copy[0:-1] + HostInfo.clipboard.set(to_copy) + self.toast_overlay.add_toast(Adw.Toast(title=_("Copied Snapshot Paths"))) + + def select_new_handler(self): + packages = [] + for row in self.selected_active_rows: + if os.path.exists(row.package.data_path): + packages.append(row.package) + + if len(packages) == 0: + self.toast_overlay.add_toast(Adw.Toast(title=_("No apps in your selection can be snapshotted"))) + return + + self.new_snapshot_dialog = NewSnapshotDialog(self, self.snapshotting_status, self.refresh, packages) + self.new_snapshot_dialog.present(HostInfo.main_window) + + def get_snapshots_from_entry(self, app_ids): + id_to_tar = {} + for app_id in app_ids: + path = f"{HostInfo.snapshots_path}{app_id}" + if not os.path.exists(path): + continue + + tarlist = [] + for file in os.listdir(path): + if file.endswith(".tar.zst"): + tarlist.append(file) + + id_to_tar[app_id] = tarlist + if len(tarlist) < 1: + id_to_tar.pop(app_id, None) + + return id_to_tar + + def get_total_fraction(self): + total = 0 + stopped_workers_amount = 0 + for worker in self.workers: + total += worker.fraction + if worker.stop: + stopped_workers_amount += 1 + + if stopped_workers_amount == len(self.workers): + self.snapshotting_status.progress_bar.set_fraction(1) + self.snapshotting_status.progress_label.set_label(f"{len(self.workers)} / {len(self.workers)}") + HostInfo.main_window.refresh_handler() + self.workers.clear() + return False + + self.snapshotting_status.progress_label.set_label(f"{stopped_workers_amount + 1} / {len(self.workers)}") + self.snapshotting_status.progress_bar.set_fraction(total / len(self.workers)) + return True + + def on_apply_response(self, dialog, response): + if response != "continue": + return + + app_ids = [] + for row in self.selected_active_rows: + app_ids.append(row.package.info['id']) + + for row in self.selected_leftover_rows: + app_ids.append(row.folder) + + id_to_tar = self.get_snapshots_from_entry(app_ids) + for app_id in id_to_tar: + biggest = 0 + biggest_tar = "" + for tar in id_to_tar[app_id]: + epoch = int(tar.split('_')[0]) + if epoch > biggest: + biggest = epoch + biggest_tar = tar + + id_to_tar[app_id] = tar + + for app_id, tar in id_to_tar.items(): + worker = TarWorker( + existing_path=f"{HostInfo.snapshots_path}{app_id}/{tar}", + new_path=f"{HostInfo.home}/.var/app/{app_id}/", + toast_overlay=self.toast_overlay, + ) + self.workers.append(worker) + worker.extract() + + if len(self.workers) > 0: + self.snapshotting_status.title_label.set_label(_("Applying Snapshots")) + self.snapshotting_status.progress_bar.set_fraction(0.0) + self.snapshotting_status.progress_label.set_visible(len(self.workers) > 1) + self.status_stack.set_visible_child(self.snapshotting_view) + GLib.timeout_add(200, self.get_total_fraction) + else: + self.toast_overlay.add_toast(ErrorToast(_("No snapshots to extract"), _("No snapshots were found to extract"))) + + def select_apply_handler(self): + dialog = Adw.AlertDialog(heading=_("Apply These Snapshots?"), body=_("This will trash the current apps' user data, and apply their newest snapshot")) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("continue", _("Continue")) + dialog.connect("response", self.on_apply_response) + dialog.present(HostInfo.main_window) + + def install_handler(self): + package_names = [] + for row in self.selected_leftover_rows: + package_names.append(row.folder) + + AttemptInstallDialog(package_names, lambda is_valid: self.select_button.set_active(not is_valid)) + + def selection_trash_handler(self): + if ( + len(self.selected_active_rows) + len(self.selected_leftover_rows) < 1 + or self.is_trash_dialog_open + ): + return + + def on_response(dialog, response): + self.is_trash_dialog_open = False + to_trash = [] + if response != "continue": + return + + for row in self.selected_active_rows: + to_trash.append(f"{HostInfo.snapshots_path}{row.package.info['id']}") + + for row in self.selected_leftover_rows: + to_trash.append(f"{HostInfo.snapshots_path}{row.folder}") + + try: + subprocess.run(['gio', 'trash'] + to_trash, check=True, text=True, capture_output=True) + self.start_loading() + self.end_loading() + self.toast_overlay.add_toast(Adw.Toast(title=_("Trashed snapshots"))) + except subprocess.CalledProcessError as cpe: + self.toast_overlay.add_toast(ErrorToast(_("Could not trash snapshots"), cpe.stderr).toast) + + self.is_trash_dialog_open = True + dialog = Adw.AlertDialog(heading=_("Trash Snapshots?"), body=_("These apps' snapshots will be sent to the trash")) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("continue", _("Trash")) + dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.connect("response", on_response) + dialog.present(HostInfo.main_window) + + def more_menu_handler(self, listbox, row): + self.more_popover.popdown() + row = row.get_child() + match row: + case self.new_snapshots: + self.select_new_handler() + case self.apply_snapshots: + self.select_apply_handler() + case self.install_from_snapshots: + self.install_handler() + case self.trash_snapshots: + self.selection_trash_handler() + + def __init__(self, main_window, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.__class__.instance = self + self.main_window = main_window + self.active_snapshot_paks = [] + self.selected_active_rows = [] + self.selected_leftover_rows = [] + self.workers = [] + self.leftover_snapshots = [] + self.list_page = SnapshotsListPage(self) + self.snapshotting_status = LoadingStatus("Initial Title", _("This could take a while"), True, self.on_cancel) + self.new_snapshot_dialog = None + self.on_backspace_handler = self.selection_trash_handler + self.on_escape_handler = lambda *_: self.select_button.set_active(False) + + # Apply + self.search_bar.set_key_capture_widget(HostInfo.main_window) + self.loading_view.set_content(LoadingStatus(_("Loading Snapshots"), _("This should only take a moment"))) + self.snapshotting_view.set_content(self.snapshotting_status) + self.split_view.set_content(self.list_page) + self.active_listbox.set_sort_func(self.sort_func) + self.leftover_listbox.set_sort_func(self.sort_func) + + # Connections + self.active_listbox.connect("row-activated", self.active_select_handler) + self.leftover_listbox.connect("row-activated", self.leftover_select_handler) + self.status_open_button.connect("clicked", self.open_snapshots_folder) + self.status_new_button.connect("clicked", self.on_new) + self.new_button.connect("clicked", self.on_new) + self.search_entry.connect("search-changed", self.on_search) + self.select_button.connect("toggled", self.set_selection_mode) + self.select_all_button.connect("clicked", self.select_all_handler) + self.copy_button.connect("clicked", self.select_copy_handler) + self.more_menu.connect("row-activated", self.more_menu_handler) diff --git a/src/snapshot_page/snapshots_list_page.blp b/src/snapshot_page/snapshots_list_page.blp new file mode 100644 index 0000000..384094d --- /dev/null +++ b/src/snapshot_page/snapshots_list_page.blp @@ -0,0 +1,47 @@ +using Gtk 4.0; +using Adw 1; + +template $SnapshotsListPage : Adw.NavigationPage { + title: _("Snapshots List"); + Adw.ToastOverlay toast_overlay { + Adw.ToolbarView toolbar_view { + [top] + Adw.HeaderBar { + [start] + Button open_button { + icon-name: "folder-open-symbolic"; + tooltip-text: _("Open Snapshots Folder for this App"); + } + } + ScrolledWindow { + Adw.Clamp { + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + ListBox listbox { + valign: start; + selection-mode: none; + styles ["boxed-list"] + Adw.PreferencesGroup { + Adw.ActionRow {title: "test";} + } + } + } + } + [bottom] + ActionBar { + [center] + Button new_button { + margin-top: 3; + margin-bottom: 3; + styles ["pill", "suggested-action"] + Adw.ButtonContent { + icon-name: "plus-large-symbolic"; + label: _("New Snapshot"); + } + } + } + } + } +} diff --git a/src/snapshot_page/snapshots_list_page.py b/src/snapshot_page/snapshots_list_page.py new file mode 100644 index 0000000..1c98549 --- /dev/null +++ b/src/snapshot_page/snapshots_list_page.py @@ -0,0 +1,110 @@ +from gi.repository import Adw, Gtk, Gio +from .host_info import HostInfo +from .error_toast import ErrorToast +from .snapshot_box import SnapshotBox +from .loading_status import LoadingStatus +from .new_snapshot_dialog import NewSnapshotDialog +import os + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/snapshot_page/snapshots_list_page.ui") +class SnapshotsListPage(Adw.NavigationPage): + __gtype_name__ = "SnapshotsListPage" + gtc = Gtk.Template.Child + + toolbar_view = gtc() + listbox = gtc() + toast_overlay = gtc() + open_button = gtc() + new_button = gtc() + + def thread(self, *args): + is_leftover = type(self.package_or_folder) is str + for snapshot in os.listdir(folder := f"{self.snapshots_path}{self.current_folder}/"): + if snapshot.endswith(".json"): + continue + + row = SnapshotBox(self, snapshot, folder, self.toast_overlay) + row.apply_button.set_sensitive(not is_leftover) + self.snapshots_rows.append(row) + if is_leftover: + row.apply_button.set_tooltip_text(_("App not Installed")) + + def callback(self, *args): + if len(self.snapshots_rows) == 0: + self.parent_page.refresh() + return + + for i, row in enumerate(self.snapshots_rows): + self.listbox.append(row) + self.listbox.get_row_at_index(i).set_activatable(False) + + def set_snapshots(self, package_or_folder, refresh=False): + if package_or_folder == self.package_or_folder and not refresh: + return + + folder = None + self.package_or_folder = package_or_folder + if type(package_or_folder) is str: + self.set_title(package_or_folder) + folder = package_or_folder + self.new_button.set_sensitive(False) + self.new_button.set_tooltip_text(_("App not Installed")) + else: + folder = package_or_folder.info["id"] + self.set_title(_("{} Snapshots").format(package_or_folder.info["name"])) + if os.path.exists(package_or_folder.data_path): + self.new_button.set_sensitive(True) + self.new_button.set_tooltip_text(None) + else: + self.new_button.set_sensitive(False) + self.new_button.set_tooltip_text(_("No Data Found to Snapshot")) + + self.current_folder = folder + self.snapshots_rows.clear() + self.listbox.remove_all() + + Gio.Task.new(None, None, self.callback).run_in_thread(self.thread) + + def open_snapshots_folder(self, button): + path = f"{self.snapshots_path}{self.current_folder}/" + try: + if not os.path.exists(path): + raise Exception(f"error: File '{path}' does not exist") + + Gio.AppInfo.launch_default_for_uri(f"file://{path}", None) + self.toast_overlay.add_toast(Adw.Toast.new(_("Opened snapshots folder"))) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not open folder"), str(e)).toast) + + def on_done(self): + self.parent_page.status_stack.set_visible_child(self.parent_page.split_view) + self.set_snapshots(self.package_or_folder, refresh=True) + + def on_new(self, button): + self.parent_page.new_snapshot_dialog = NewSnapshotDialog(self.parent_page, self.parent_page.snapshotting_status, self.on_done, [self.package_or_folder]) + self.parent_page.new_snapshot_dialog.present(HostInfo.main_window) + + def sort_func(self, row1, row2): + row1 = row1.get_child() + row2 = row2.get_child() + return row1.epoch > row2.epoch + + def on_trash(self): + self.set_snapshots(self.package_or_folder, refresh=True) + + def __init__(self, parent_page, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.parent_page = parent_page + self.snapshots_path = HostInfo.snapshots_path + self.current_folder = None + self.package_or_folder = None + self.snapshots_rows = [] + + # Connections + self.open_button.connect("clicked", self.open_snapshots_folder) + self.new_button.connect("clicked", self.on_new) + + # Apply + self.listbox.set_sort_func(self.sort_func) diff --git a/src/snapshot_page/tar_worker.py b/src/snapshot_page/tar_worker.py new file mode 100644 index 0000000..acda0c9 --- /dev/null +++ b/src/snapshot_page/tar_worker.py @@ -0,0 +1,119 @@ +from gi.repository import GLib, Gio +from .host_info import HostInfo +from .error_toast import ErrorToast +import os, tarfile, subprocess, json + +class TarWorker: + def compress_thread(self, *args): + try: + if not os.path.exists(self.new_path): + os.makedirs(self.new_path) + + self.total = int(subprocess.run(['du', '-s', self.existing_path], check=True, text=True, capture_output=True).stdout.split('\t')[0]) + self.total /= 2.2 # estimate for space savings + self.process = subprocess.Popen(['tar', 'cafv', f'{self.new_path}/{self.file_name}.tar.zst', '-C', self.existing_path, '.'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + stdout, stderr = self.process.communicate() + if self.process.returncode != 0: + raise subprocess.CalledProcessError(self.process.returncode, self.process.args, output=stdout, stderr=stderr) + + with open(f"{self.new_path}/{self.file_name}.json", 'w') as file: + data = { + 'snapshot_version': 1, + 'name': self.name, + } + json.dump(data, file, indent=4) + + self.stop = True # tell the check timeout to stop, because we know the file is done being made + HostInfo.main_window.remove_refresh_lockout("managing snapshot") + + except subprocess.CalledProcessError as cpe: + self.do_cancel(cpe.stderr.decode()) # stderr is in bytes, so decode it + + except Exception as e: + self.do_cancel(str(e)) + + def extract_thread(self, *args): + try: + if os.path.exists(self.new_path): + subprocess.run(['gio', 'trash', self.new_path], capture_output=True, check=True) # trash the current user data, because new data will go in its place + + os.makedirs(self.new_path) # create the new user data path + + self.total = int(subprocess.run(['du', '-s', self.existing_path], check=True, text=True, capture_output=True).stdout.split('\t')[0]) + self.total *= 2.2 # estimate from space savings + self.process = subprocess.Popen(['tar', '--zstd', '-xvf', self.existing_path, '-C', self.new_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + stdout, stderr = self.process.communicate() + if self.process.returncode != 0: + raise subprocess.CalledProcessError(self.process.returncode, self.process.args, output=stdout, stderr=stderr) + + self.stop = True # tell the check timeout to stop, because we know the file is done being made + HostInfo.main_window.remove_refresh_lockout("managing snapshot") + + except subprocess.CalledProcessError as cpe: + self.do_cancel(cpe.stderr.decode()) + + except Exception as e: + self.do_cancel(str(e)) + + def do_cancel(self, error_str): + if self.has_cancelled or self.stop: + return + + self.has_cancelled = True + self.process.terminate() + self.process.wait() + if len(self.files_to_trash_on_cancel) > 0: + try: + subprocess.run(['gio', 'trash'] + self.files_to_trash_on_cancel, capture_output=True, check=True) + + except Exception: + pass + + self.stop = True + HostInfo.main_window.remove_refresh_lockout("managing snapshot") + if self.toast_overlay and error_str != "manual_cancel": + self.toast_overlay.add_toast(ErrorToast(_("Error in snapshot handling"), error_str).toast) + + def check_size(self, check_path): + try: + output = subprocess.run(['du', '-s', check_path], check=True, text=True, capture_output=True).stdout.split('\t')[0] + working_total = float(output) + self.fraction = working_total / self.total + return not self.stop + + except subprocess.CalledProcessError as cpe: + return not self.stop # continue the timeout or stop the timeout + + def compress(self): + self.stop = False + self.files_to_trash_on_cancel = [f'{self.new_path}/{self.file_name}.tar.zst', f'{self.new_path}/{self.file_name}.json'] + HostInfo.main_window.add_refresh_lockout("managing snapshot") + Gio.Task.new(None, None, None).run_in_thread(self.compress_thread) + GLib.timeout_add(200, self.check_size, f"{self.new_path}/{self.file_name}.tar.zst") + + def extract(self): + self.stop = False + self.files_to_trash_on_cancel = [self.new_path] + HostInfo.main_window.add_refresh_lockout("managing snapshot") + Gio.Task.new(None, None, None).run_in_thread(self.extract_thread) + GLib.timeout_add(200, self.check_size, self.new_path) + + def __init__(self, existing_path, new_path, file_name="", name="", toast_overlay=None): + self.existing_path = existing_path + self.new_path = new_path + self.file_name = file_name + self.name = name + self.should_check = False + self.stop = False + self.fraction = 0.0 + self.total = 0 + self.process = None + self.toast_overlay = toast_overlay + self.has_cancelled = False + self.files_to_trash_on_cancel = [] diff --git a/src/snapshots_window.py b/src/snapshots_window.py deleted file mode 100644 index 62efcd3..0000000 --- a/src/snapshots_window.py +++ /dev/null @@ -1,281 +0,0 @@ -from gi.repository import Gtk, Adw, GLib, Gdk, Gio -from .common import myUtils -import subprocess -import os -import pathlib -import time - - -@Gtk.Template(resource_path="/io/github/flattool/Warehouse/../data/ui/snapshots.ui") -class SnapshotsWindow(Adw.Dialog): - __gtype_name__ = "SnapshotsWindow" - - new_env = dict(os.environ) - new_env["LC_ALL"] = "C" - host_home = str(pathlib.Path.home()) - user_data_path = host_home + "/.var/app/" - snapshots_path = ( - host_home + "/.var/app/io.github.flattool.Warehouse/data/Snapshots/" - ) - - snapshots_group = Gtk.Template.Child() - main_stack = Gtk.Template.Child() - no_snapshots = Gtk.Template.Child() - new_snapshot = Gtk.Template.Child() - open_folder_button = Gtk.Template.Child() - toast_overlay = Gtk.Template.Child() - outerbox = Gtk.Template.Child() - loading = Gtk.Template.Child() - loading_label = Gtk.Template.Child() - action_bar = Gtk.Template.Child() - - def show_list_or_empty(self): - # Make window able to close - self.set_can_close(True) - - self.action_bar.set_revealed(True) - if os.path.exists(self.snapshots_of_app_path): - if len(os.listdir(self.snapshots_of_app_path)) > 0: - self.main_stack.set_visible_child(self.outerbox) - return "list" - self.open_folder_button.set_sensitive(False) - self.main_stack.set_visible_child(self.no_snapshots) - return "empty" - - def generate_list(self): - if not os.path.exists(self.app_user_data): - self.new_snapshot.set_sensitive(False) - self.new_snapshot.set_tooltip_text(_("There is no User Data to Snapshot")) - - if self.show_list_or_empty() == "empty": - return - - snapshot_files = os.listdir(self.snapshots_of_app_path) - to_trash = [] - - for i in range(len(snapshot_files)): - if not snapshot_files[i].endswith(".tar.zst"): - # Find all files that aren't snapshots - to_trash.append(snapshot_files[i]) - - for i in range(len(to_trash)): - # Trash all files that aren't snapshots - a = self.my_utils.trash_folder(f"{self.snapshots_of_app_path}{to_trash[i]}") - if a == 0: - snapshot_files.remove(to_trash[i]) - - if len(snapshot_files) == 0: - self.main_stack.set_visible_child(self.no_snapshots) - return - - for i in range(len(snapshot_files)): - self.create_row(snapshot_files[i]) - - def create_row(self, file): - def size_thread(*args): - size = self.my_utils.get_size_with_format(self.snapshots_of_app_path + file) - GLib.idle_add(lambda *_a: row.set_subtitle(f"~{size}")) - - split_file = file.removesuffix(".tar.zst").split("_") - time = GLib.DateTime.new_from_unix_local(int(split_file[0])).format("%x %X") - row = Adw.ActionRow(title=time) - - task = Gio.Task() - task.run_in_thread(size_thread) - - label = Gtk.Label( - label=_("Version {}").format(split_file[1]), - hexpand=True, - wrap=True, - justify=Gtk.Justification.RIGHT, - ) - row.add_suffix(label) - - apply = Gtk.Button(icon_name="check-plain-symbolic", valign=Gtk.Align.CENTER) - apply.set_tooltip_text(_("Apply Snapshot")) - apply.connect("clicked", self.apply_snapshot, file, row) - apply.add_css_class("flat") - row.add_suffix(apply) - - trash = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER) - trash.set_tooltip_text(_("Trash Snapshot")) - trash.connect("clicked", self.trash_snapshot, file, row) - trash.add_css_class("flat") - row.add_suffix(trash) - self.snapshots_group.insert(row, 0) - self.main_stack.set_visible_child(self.outerbox) - self.open_folder_button.set_sensitive(True) - - def trash_snapshot(self, button, file, row): - def on_response(dialog, response, func): - if response == "cancel": - return - a = self.my_utils.trash_folder(self.snapshots_of_app_path + file) - if a == 0: - self.snapshots_group.remove(row) - if not self.snapshots_group.get_row_at_index(0): - self.my_utils.trash_folder(self.snapshots_of_app_path) - self.show_list_or_empty() - else: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not trash snapshot")) - ) - - dialog = Adw.AlertDialog.new( - _("Trash Snapshot?"), - _("This snapshot and its contents will be sent to the trash."), - ) - dialog.add_response("cancel", _("Cancel")) - dialog.set_close_response("cancel") - dialog.add_response("continue", _("Trash Snapshot")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.connect("response", on_response, dialog.choose_finish) - dialog.present(self) - - def create_snapshot(self): - epoch = int(time.time()) - - def thread(): - response = self.my_utils.snapshot_apps( - epoch, - [self.snapshots_of_app_path], - [self.app_version], - [self.app_user_data], - ) - if response != 0: - GLib.idle_add( - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not create snapshot")) - ) - ) - return - if self.show_list_or_empty() == "list": - self.create_row(f"{epoch}_{self.app_version}.tar.zst") - - # Make window unable to close - self.set_can_close(False) - self.loading_label.set_label(_("Creating Snapshotâ€Ļ")) - self.action_bar.set_revealed(False) - self.main_stack.set_visible_child(self.loading) - - task = Gio.Task() - task.run_in_thread(lambda *_: thread()) - - def apply_snapshot(self, button, file, row): - self.applied = False - - def thread(): - try: - subprocess.run( - [ - "tar", - "--zstd", - "-xvf", - f"{self.snapshots_of_app_path}{file}", - "-C", - f"{self.app_user_data}", - ], - check=True, - env=self.new_env, - ) - self.applied = True - except subprocess.CalledProcessError as e: - print( - "error in snapshots_window.apply_snapshot.thread: CalledProcessError:", - e, - ) - - def callback(): - if not self.applied: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not apply snapshot")) - ) - else: - self.toast_overlay.add_toast(Adw.Toast.new(_("Snapshot applied"))) - - self.new_snapshot.set_tooltip_text("") - self.show_list_or_empty() - - def on_response(dialog, response, func): - if response == "cancel": - return - to_apply = self.snapshots_of_app_path + file - to_trash = self.app_user_data - if os.path.exists(to_trash): - a = self.my_utils.trash_folder(to_trash) - if a != 0: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not apply snapshot")) - ) - return - data = Gio.File.new_for_path(self.app_user_data) - data.make_directory() - if not os.path.exists(data.get_path()): - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not apply snapshot")) - ) - return - - # Make window unable to close - self.set_can_close(False) - self.loading_label.set_label(_("Applying Snapshotâ€Ļ")) - self.action_bar.set_revealed(False) - self.main_stack.set_visible_child(self.loading) - - task = Gio.Task.new(None, None, lambda *_: callback()) - task.run_in_thread(lambda *_: thread()) - - dialog = Adw.AlertDialog.new( - _("Apply Snapshot?"), - _("Applying this snapshot will trash any current user data for {}.").format( - self.app_name - ), - ) - dialog.add_response("cancel", _("Cancel")) - dialog.set_close_response("cancel") - dialog.add_response("continue", _("Apply Snapshot")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.connect("response", on_response, dialog.choose_finish) - dialog.present(self) - - def open_button_handler(self, widget, path): - try: - Gio.AppInfo.launch_default_for_uri(f"file://{path}", None) - except GLib.GError: - self.toast_overlay.add_toast(Adw.Toast.new(_("Could not open folder"))) - - def __init__(self, parent_window, flatpak_row, **kwargs): - super().__init__(**kwargs) - - # Variables - self.my_utils = myUtils(self) - self.app_name = flatpak_row[0] - self.app_id = flatpak_row[2] - self.app_version = flatpak_row[3] - self.app_ref = flatpak_row[8] - self.snapshots_of_app_path = self.snapshots_path + self.app_id + "/" - self.app_user_data = self.user_data_path + self.app_id + "/" - self.parent_window = parent_window - - if ( - self.app_version == "" - or self.app_version == "-" - or self.app_version == None - ): - self.app_version = 0.0 - - if not os.path.exists(self.snapshots_path): - # Create snapshots folder if none exists - file = Gio.File.new_for_path(self.snapshots_path) - file.make_directory() - - # Calls - self.generate_list() - self.open_folder_button.connect( - "clicked", self.open_button_handler, self.snapshots_of_app_path - ) - self.new_snapshot.connect("clicked", lambda *_: self.create_snapshot()) - - # Window stuffs - self.set_title(_("{} Snapshots").format(self.app_name)) - self.present(parent_window) diff --git a/src/user_data_page/data_box.blp b/src/user_data_page/data_box.blp new file mode 100644 index 0000000..98e5686 --- /dev/null +++ b/src/user_data_page/data_box.blp @@ -0,0 +1,84 @@ +using Gtk 4.0; +using Adw 1; + +template $DataBox : ListBox { + selection-mode: none; + styles ["boxed-list"] + Adw.ActionRow row { + activatable: bind check_button.visible; + width-request: 275; + [child] + Box root_box { + orientation: vertical; + Box title_box { + margin-top: 12; + margin-bottom: 12; + Image image { + margin-start: 12; + margin-end: 12; + icon-name: "flatpak-symbolic"; + icon-size: large; + } + Box label_box { + orientation: vertical; + Label title_label { + label: "No Title Set"; + hexpand: true; + halign: start; + ellipsize: middle; + margin-end: 12; + styles ["title-4"] + } + Label subtitle_label { + label: "No subtitle set"; + // hexpand: true; + halign: start; + ellipsize: middle; + margin-end: 12; + } + } + } + Box content_box { + spacing: 6; + margin-start: 12; + margin-end: 6; + margin-bottom: 6; + Spinner spinner { + spinning: true; + } + Label size_label { + label: "No size set"; + halign: start; + hexpand: true; + } + Button copy_button { + icon-name: "copy-symbolic"; + tooltip-text: _("Copy Path"); + visible: bind check_button.visible inverted; + styles ["flat", "circular"] + } + Button open_button { + icon-name: "folder-open-symbolic"; + tooltip-text: _("Open User Data"); + visible: bind check_button.visible inverted; + styles ["flat", "circular"] + } + Button install_button { + icon-name: "arrow-pointing-at-line-down-symbolic"; + tooltip-text: _("Attempt to Install"); + styles ["flat", "circular"] + } + Button trash_button { + icon-name: "user-trash-symbolic"; + tooltip-text: _("Trash User Data"); + visible: bind check_button.visible inverted; + styles ["flat", "circular"] + } + CheckButton check_button { + visible: false; + styles ["selection-mode"] + } + } + } + } +} diff --git a/src/user_data_page/data_box.py b/src/user_data_page/data_box.py new file mode 100644 index 0000000..4729630 --- /dev/null +++ b/src/user_data_page/data_box.py @@ -0,0 +1,148 @@ +from gi.repository import Adw, Gtk, GLib, Gio +from .host_info import HostInfo +from .error_toast import ErrorToast +from .attempt_install_dialog import AttemptInstallDialog +import subprocess + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/user_data_page/data_box.ui") +class DataBox(Gtk.ListBox): + __gtype_name__ = 'DataBox' + gtc = Gtk.Template.Child + + row = gtc() + image = gtc() + title_label = gtc() + subtitle_label = gtc() + spinner = gtc() + size_label = gtc() + + copy_button = gtc() + open_button = gtc() + install_button = gtc() + trash_button = gtc() + check_button = gtc() + + def human_readable_size(self): + working_size = self.size + units = ['KB', 'MB', 'GB', 'TB'] + # size *= 1024 + for unit in units: + if working_size < 1024: + return f"~ {round(working_size)} {unit}" + working_size /= 1024 + return f"~ {round(working_size)} PB" + + def get_size(self, *args): + self.size = int(subprocess.run(['du', '-s', self.data_path], capture_output=True, text=True).stdout.split("\t")[0]) + + def show_size(self): + def callback(*args): + self.size_label.set_label(self.human_readable_size()) + self.spinner.set_visible(False) + if self.callback: + self.callback(self.size) + + Gio.Task.new(None, None, callback).run_in_thread(self.get_size) + + def idle_stuff(self): + self.title_label.set_label(self.title) + self.subtitle_label.set_label(self.subtitle) + self.install_button.set_visible(self.is_leftover) + if self.icon_path: + self.image.add_css_class("icon-dropshadow") + self.image.set_from_file(self.icon_path) + + def copy_handler(self, *args): + try: + HostInfo.clipboard.set(self.data_path) + self.toast_overlay.add_toast(Adw.Toast.new(_("Copied data path"))) + except Exception as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not copy data path"), str(e)).toast) + + def open_handler(self, *args): + try: + Gio.AppInfo.launch_default_for_uri(f"file://{self.data_path}", None) + self.toast_overlay.add_toast(Adw.Toast.new(_("Opened data folder"))) + except GLib.GError as e: + self.toast_overlay.add_toast(ErrorToast(_("Could not open folder"), str(e)).toast) + + def install_handler(self, *args): + self.parent_page.should_rclick = False + def why_cant_this_just_be_a_lambda(*args): + self.parent_page.should_rclick = True + + AttemptInstallDialog([self.subtitle], why_cant_this_just_be_a_lambda) + + def trash_handler(self, *args): + self.failed_trash = False + + def thread(*args): + try: + subprocess.run(['gio', 'trash', self.data_path], check=True, text=True, capture_output=True) + properties_page = HostInfo.main_window.pages[HostInfo.main_window.packages_row].properties_page + properties_package = properties_page.package + if not properties_package is None: + properties_page.set_properties(properties_package, True) + + snapshot_list_page = HostInfo.main_window.pages[HostInfo.main_window.snapshots_row].list_page + snapshot_list_package = snapshot_list_page.package_or_folder + if not snapshot_list_package is None: + snapshot_list_page.set_snapshots(snapshot_list_package, True) + + except subprocess.CalledProcessError as cpe: + self.failed_trash = cpe.stderr + except Exception as e: + self.failed_trash = e + + def callback(*args): + if self.failed_trash: + self.toast_overlay.add_toast(ErrorToast(_("Could not trash data"), str(self.failed_trash)).toast) + else: + self.toast_overlay.add_toast(Adw.Toast.new("Trashed data")) + if self.trash_callback: + self.trash_callback(self) + + def on_response(_, response): + self.parent_page.should_rclick = True + if response != "continue": + return + + Gio.Task.new(None, None, callback).run_in_thread(thread) + + self.parent_page.should_rclick = False + dialog = Adw.AlertDialog(heading=_("Trash {}'s Data?").format(self.title), body=_("{}'s data will be sent to the trash").format(self.title)) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("continue", _("Continue")) + dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.connect("response", on_response) + dialog.present(HostInfo.main_window) + + def __init__(self, parent_page, toast_overlay, is_leftover, title, subtitle, data_path, icon_path=None, callback=None, trash_callback=None, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.parent_page = parent_page + self.toast_overlay = toast_overlay + self.is_leftover = is_leftover + self.title = title + self.subtitle = subtitle + self.icon_path = icon_path + self.data_path = data_path + self.callback = callback + self.trash_callback = trash_callback + self.size = None + self.failed_trash = None + + # Apply + self.idle_stuff() + self.show_size() + if subtitle == "io.github.flattool.Warehouse": + self.check_button.set_active = lambda *_: None + self.check_button.set_sensitive(False) + self.trash_button.set_sensitive(False) + + # Connections + self.copy_button.connect("clicked", self.copy_handler) + self.open_button.connect("clicked", self.open_handler) + self.install_button.connect("clicked", self.install_handler) + self.trash_button.connect("clicked", self.trash_handler) diff --git a/src/user_data_page/data_subpage.blp b/src/user_data_page/data_subpage.blp new file mode 100644 index 0000000..965c49a --- /dev/null +++ b/src/user_data_page/data_subpage.blp @@ -0,0 +1,73 @@ +using Gtk 4.0; +using Adw 1; + +template $DataSubpage : Stack { + Box content_box { + orientation: vertical; + Box label_box { + margin-start: 24; + margin-end: 24; + halign: fill; + hexpand: true; + Label title { + label: _("No Title Set"); + styles ["title-1"] + hexpand: true; + justify: fill; + halign: start; + wrap: true; + } + Box subtitle_size_box { + Spinner spinner { + spinning: true; + valign: center; + margin-top: 3; + margin-end: 6; + } + Label size_label { + label: _("Loading Sizeâ€Ļ"); + styles ["title-3"] + halign: start; + wrap: true; + } + Label subtitle { + visible: false; + label: "No Subtutle Set"; + styles ["title-3"] + wrap: true; + } + } + margin-bottom: 9; + } + ScrolledWindow scrolled_window { + vexpand: true; + Box { + orientation: vertical; + Separator { + margin-start: 12; + margin-end: 12; + margin-bottom: 9; + } + FlowBox flow_box { + styles ["boxed-list"] + homogeneous: true; + valign: start; + selection-mode: none; + max-children-per-line: 6; + margin-start: 12; + margin-end: 12; + margin-bottom: 12; + } + } + } + } + Adw.StatusPage no_data { + // Contents will be set from the subpage object + } + Adw.StatusPage no_results { + title: _("No Results Found"); + description: _("Try a different search"); + icon-name: "system-search-symbolic"; + valign: center; + } +} diff --git a/src/user_data_page/data_subpage.py b/src/user_data_page/data_subpage.py new file mode 100644 index 0000000..c08a75e --- /dev/null +++ b/src/user_data_page/data_subpage.py @@ -0,0 +1,242 @@ +from gi.repository import Gtk, GLib, Gio +from .host_info import HostInfo +from .error_toast import ErrorToast +from .data_box import DataBox +from .loading_status import LoadingStatus + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/user_data_page/data_subpage.ui") +class DataSubpage(Gtk.Stack): + __gtype_name__ = 'DataSubpage' + gtc = Gtk.Template.Child + + scrolled_window = gtc() + + label_box = gtc() + subtitle_size_box = gtc() + title = gtc() + subtitle = gtc() + spinner = gtc() + size_label = gtc() + flow_box = gtc() + + # Statuses + content_box = gtc() + no_data = gtc() + no_results = gtc() + + def human_readable_size(self): + working_size = self.total_size + units = ['KB', 'MB', 'GB', 'TB'] + # size *= 1024 + for unit in units: + if working_size < 1024: + return f"~ {round(working_size)} {unit}" + working_size /= 1024 + return f"~ {round(working_size)} PB" + + def sort_func(self, box1, box2): + import random + # print(random.randint(1, 100), self.sort_mode, self.sort_ascend) + i1 = None + i2 = None + if self.sort_mode == "name": + i1 = box1.get_child().title.lower() + i2 = box2.get_child().title.lower() + + if self.sort_mode == "id": + i1 = box1.get_child().subtitle.lower() + i2 = box2.get_child().subtitle.lower() + + if self.sort_mode == "size" and self.ready_to_sort_size: + i1 = box1.get_child().size + i2 = box2.get_child().size + + if i1 is None or i2 is None: + return 0 + + return i1 > i2 if self.sort_ascend else i1 < i2 + + def box_size_callback(self, size): + self.finished_boxes += 1 + self.total_size += size + if self.finished_boxes == self.total_items: + self.size_label.set_label(self.human_readable_size()) + self.spinner.set_visible(False) + self.ready_to_sort_size = True + if self.sort_mode == "size": + self.flow_box.invalidate_sort() + self.set_visible_child(self.content_box) + GLib.idle_add(lambda *_: self.parent_page.status_stack.set_visible_child(self.parent_page.main_view)) + + def trash_handler(self, trashed_box): + self.flow_box.remove(trashed_box) + if not self.flow_box.get_child_at_index(0): + self.set_visible_child(self.no_data) + self.parent_page.start_loading() + self.parent_page.end_loading() + + def set_selection_mode(self, is_enabled): + if not is_enabled: + self.size_label.set_visible(True) + self.subtitle.set_visible(False) + + idx = 0 + while box := self.flow_box.get_child_at_index(idx): + idx += 1 + box = box.get_child() + if not is_enabled: + GLib.idle_add(lambda *_, box=box: box.check_button.set_active(False)) + + GLib.idle_add(lambda *_, box=box: box.check_button.set_visible(is_enabled)) + GLib.idle_add(lambda *_, box=box: box.install_button.set_visible(box.is_leftover and not is_enabled)) + + self.selected_boxes.clear() + + def box_select_handler(self, box): + cb = box.check_button + if cb.get_active(): + self.selected_boxes.append(box) + else: + try: + self.selected_boxes.remove(box) + except ValueError: + pass + + total = len(self.selected_boxes) + self.subtitle.set_visible(not total == 0) + self.size_label.set_visible(total == 0) + self.subtitle.set_label(_("{} Selected").format(total)) + self.parent_page.copy_button.set_sensitive(total) + self.parent_page.trash_button.set_sensitive(total) + self.parent_page.install_button.set_sensitive(total) + self.parent_page.more_button.set_sensitive(total) + + def box_interact_handler(self, flow_box, box): + box = box.get_child() + cb = box.check_button + if cb.get_visible(): + cb.set_active(not cb.get_active()) + + def select_all_handler(self, *args): + idx = 0 + while box := self.flow_box.get_child_at_index(idx): + idx += 1 + box.get_child().check_button.set_active(True) + + def box_rclick_handler(self, box): + if self.should_rclick: + self.parent_page.select_button.set_active(True) + box.check_button.set_active(not box.check_button.get_active()) + + def generate_list(self, flatpaks, data): + self.flow_box.remove_all() + self.boxes.clear() + self.ready_to_sort_size = False + self.finished_boxes = 0 + self.total_size = 0 + self.total_items = len(data) + self.parent_page.search_entry.set_editable(True) + self.should_rclick = True + if flatpaks: + for i, pak in enumerate(flatpaks): + box = DataBox(self, self.parent_page.toast_overlay, False, pak.info["name"], pak.info["id"], pak.data_path, pak.icon_path, self.box_size_callback, self.trash_handler) + box.check_button.connect("toggled", lambda *_, box=box: self.box_select_handler(box)) + self.boxes.append(box) + self.flow_box.append(box) + + else: + for i, folder in enumerate(data): + box = DataBox(self, self.parent_page.toast_overlay, True, folder.split('.')[-1], folder, f"{HostInfo.home}/.var/app/{folder}", None, self.box_size_callback, self.trash_handler) + box.check_button.connect("toggled", lambda *_, box=box: self.box_select_handler(box)) + self.flow_box.append(box) + + idx = 0 + while box := self.flow_box.get_child_at_index(idx): + idx += 1 + box.set_focusable(False) + child = box.get_child() + child.set_focusable(False) + child.row.set_focusable(child.check_button.get_visible()) + rclick = Gtk.GestureClick(button=3) + rclick.connect("released", lambda *_, child=child: self.box_rclick_handler(child)) + box.add_controller(rclick) + long_press = Gtk.GestureLongPress() + long_press.connect("pressed", lambda *_, child=child: self.box_rclick_handler(child)) + box.add_controller(long_press) + + if idx == 0: + self.set_visible_child(self.no_data) + elif self.sort_mode != "size": + self.set_visible_child(self.content_box) + self.parent_page.status_stack.set_visible_child(self.parent_page.main_view) + + def filter_func(self, box): + search_text = self.parent_page.search_entry.get_text().lower() + box = box.get_child() + if search_text in box.title.lower() or search_text in box.subtitle.lower(): + self.is_result = True + return True + + def on_invalidate(self, box): + current_status = self.get_visible_child() + if not current_status is self.no_results: + self.prev_status = self.get_visible_child() + + self.is_result = False + self.flow_box.invalidate_filter() + if self.is_result: + self.set_visible_child(self.prev_status) + else: + self.set_visible_child(self.no_results) + + if self.parent_page.search_entry.get_text().lower() != "" and self.total_items == 0: + self.set_visible_child(self.no_results) + elif self.total_items == 0: + self.set_visible_child(self.no_data) + + def update_sort_mode(self): + self.sort_ascend = self.settings.get_boolean("sort-ascend") + self.sort_mode = self.settings.get_string("sort-mode") + self.flow_box.invalidate_sort() + + def __init__(self, title, parent_page, is_active, main_window, **kwargs): + super().__init__(**kwargs) + + GLib.idle_add(lambda *_: self.title.set_label(title)) + + # self.select_button.connect("toggled", lambda *_: self.set_selection_mode(self.select_button.get_active())) + # self.flow_box.connect("child-activated", lambda _, item: (cb := (row := item.get_child()).check_button).set_active((not cb.get_active()) if row.get_activatable() else False)) + + # Extra Object Creation + self.main_window = main_window + self.parent_page = parent_page + # self.is_active = is_active + self.sort_mode = "" + self.sort_ascend = False + self.total_size = 0 + self.total_items = 0 + self.boxes = [] + self.selected_boxes = [] + self.ready_to_sort_size = False + self.should_rclick = True + self.finished_boxes = 0 + self.is_result = False + self.prev_status = None + self.settings = Gio.Settings.new("io.github.flattool.Warehouse.data_page") + + # Apply + self.flow_box.set_sort_func(self.sort_func) + self.flow_box.set_filter_func(self.filter_func) + + if is_active: + self.no_data.set_icon_name("error-symbolic") + self.no_data.set_title(_("No Active Data")) + self.no_data.set_description(_("Warehouse cannot see any active user data or your system has no active user data present")) + else: + self.no_data.set_icon_name("check-plain-symbolic") + self.no_data.set_title(_("No Leftover Data")) + self.no_data.set_description(_("There is no leftover user data")) + + # Connections + parent_page.search_entry.connect("search-changed", self.on_invalidate) + self.flow_box.connect("child-activated", self.box_interact_handler) diff --git a/src/user_data_page/user_data_page.blp b/src/user_data_page/user_data_page.blp new file mode 100644 index 0000000..a58fe52 --- /dev/null +++ b/src/user_data_page/user_data_page.blp @@ -0,0 +1,211 @@ +using Gtk 4.0; +using Adw 1; + +template $UserDataPage : Adw.BreakpointBin { + width-request: 1; + height-request: 1; + + Adw.Breakpoint bpt { + condition ("max-width: 585") + + setters { + header_bar.title-widget: null; + // header_bar.show-title: false; + switcher_bar.reveal: true; + switcher_bar.visible: true; + } + } + + Adw.NavigationPage { + title: _("User Data"); + Stack status_stack { + Adw.ToolbarView loading_view { + [top] + Adw.HeaderBar { + [start] + $SidebarButton {} + } + } + Adw.ToolbarView main_view { + [top] + Adw.HeaderBar header_bar { + title-widget: + Adw.ViewSwitcher { + stack: stack; + policy: wide; + } + ; + [start] + $SidebarButton {} + [start] + ToggleButton search_button { + icon-name: "system-search-symbolic"; + tooltip-text: _("Search User Data"); + } + [end] + MenuButton sort_button { + popover: sort_pop; + icon-name: "vertical-arrows-long-symbolic"; + tooltip-text: _("Sort User Data"); + } + [end] + ToggleButton select_button { + icon-name: "selection-mode-symbolic"; + tooltip-text: _("Select User Data"); + } + } + [top] + Adw.Clamp { + SearchBar search_bar { + search-mode-enabled: bind search_button.active bidirectional; + SearchEntry search_entry { + editable: false; + hexpand: true; + placeholder-text: _("Search User Data"); + } + } + } + [bottom] + Revealer revealer { + reveal-child: bind select_button.active; + transition-type: slide_up; + [center] + Box bottom_bar { + styles ["toolbar"] + hexpand: true; + homogeneous: true; + Button select_all_button { + styles ["raised"] + Adw.ButtonContent { + icon-name: "selection-mode-symbolic"; + label: _("Select All"); + can-shrink: true; + } + } + Button copy_button { + sensitive: false; + styles ["raised"] + Adw.ButtonContent { + icon-name: "edit-copy-symbolic"; + label: _("Copy"); + can-shrink: true; + } + } + Button install_button { + visible: false; + sensitive: false; + styles ["raised"] + Adw.ButtonContent { + icon-name: "arrow-pointing-at-line-down-symbolic"; + label: _("Install"); + can-shrink: true; + } + } + Button trash_button { + sensitive: false; + styles ["raised"] + Adw.ButtonContent { + icon-name: "user-trash-symbolic"; + label: _("Move to Trash"); + can-shrink: true; + } + } + MenuButton more_button { + visible: false; + sensitive: false; + popover: more_popover; + styles ["raised"] + Adw.ButtonContent { + icon-name: "view-more-symbolic"; + label: _("More"); + can-shrink: true; + } + } + } + } + [bottom] + Adw.ViewSwitcherBar switcher_bar { + stack: stack; + visible: false; + } + Adw.ToastOverlay toast_overlay { + Adw.ViewStack stack { + } + } + } + } + } +} + +Popover more_popover { + styles ["menu"] + ListBox more_menu { + Label more_install { + label: _("Install"); + halign: start; + } + Label more_trash { + label: _("Move to Trash"); + halign: start; + } + } +} + +Popover sort_pop { + styles ["menu"] + Box { + orientation: vertical; + margin-start: 6; + margin-end: 6; + margin-top: 6; + margin-bottom: 6; + Box { + homogeneous: true; + spacing: 3; + ToggleButton sort_ascend { + styles ["flat"] + Adw.ButtonContent { + icon-name: "view-sort-ascending-symbolic"; + label: _("Ascending"); + } + } + ToggleButton sort_descend { + group: sort_ascend; + styles ["flat"] + Adw.ButtonContent { + icon-name: "view-sort-descending-symbolic"; + label: _("Descending"); + } + } + } + Separator { + } + Box { + homogeneous: true; + spacing: 3; + ToggleButton sort_name { + styles ["flat"] + Adw.ButtonContent { + icon-name: "font-x-generic-symbolic"; + label: _("Name"); + } + } + ToggleButton sort_id { + group: sort_name; + styles ["flat"] + Adw.ButtonContent { + icon-name: "tag-outline-symbolic"; + label: _("ID"); + } + } + ToggleButton sort_size { + group: sort_name; + styles ["flat"] + Adw.ButtonContent { + icon-name: "harddisk-symbolic"; + label: _("Size"); + } + } + } + } +} diff --git a/src/user_data_page/user_data_page.py b/src/user_data_page/user_data_page.py new file mode 100644 index 0000000..cd7610b --- /dev/null +++ b/src/user_data_page/user_data_page.py @@ -0,0 +1,299 @@ +from gi.repository import Adw, Gtk, Gio, Gdk +from .error_toast import ErrorToast +from .data_box import DataBox +from .data_subpage import DataSubpage +from .host_info import HostInfo +from .sidebar_button import SidebarButton +from .loading_status import LoadingStatus +from .attempt_install_dialog import AttemptInstallDialog +import os, subprocess + +@Gtk.Template(resource_path="/io/github/flattool/Warehouse/user_data_page/user_data_page.ui") +class UserDataPage(Adw.BreakpointBin): + __gtype_name__ = 'UserDataPage' + gtc = Gtk.Template.Child + + bpt = gtc() + status_stack = gtc() + loading_view = gtc() + main_view = gtc() + header_bar = gtc() + switcher_bar = gtc() + search_button = gtc() + select_button = gtc() + sort_button = gtc() + search_bar = gtc() + search_entry = gtc() + toast_overlay = gtc() + stack = gtc() + revealer = gtc() + + sort_ascend = gtc() + sort_descend = gtc() + sort_name = gtc() + sort_id = gtc() + sort_size = gtc() + + select_all_button = gtc() + copy_button = gtc() + trash_button = gtc() + install_button = gtc() + more_button = gtc() + more_popover = gtc() + more_menu = gtc() + more_trash = gtc() + more_install = gtc() + + # Referred to in the main window + # It is used to determine if a new page should be made or not + # This must be set to the created object from within the class's __init__ method + instance = None + page_name = "user-data" + data_path = f"{HostInfo.home}/.var/app" + bpt_is_applied = False + is_trash_dialog_open = False + + def sort_data(self, *args): + self.data_flatpaks.clear() + self.active_data.clear() + self.leftover_data.clear() + # paks = dict(HostInfo.id_to_flatpak) + + if not os.path.exists(self.data_path): + return + + for folder in os.listdir(self.data_path): + try: + self.data_flatpaks.append(HostInfo.id_to_flatpak[folder]) + self.active_data.append(folder) + except KeyError: + self.leftover_data.append(folder) + + def start_loading(self, *args): + self.status_stack.set_visible_child(self.loading_view) + self.search_button.set_active(False) + self.select_button.set_active(False) + self.adp.size_label.set_label(_("Loading Size")) + self.adp.spinner.set_visible(True) + self.ldp.size_label.set_label(_("Loading Size")) + self.ldp.spinner.set_visible(True) + + def end_loading(self, *args): + def callback(*args): + self.adp.generate_list(self.data_flatpaks, self.active_data) + self.ldp.generate_list([], self.leftover_data) + + Gio.Task.new(None, None, callback).run_in_thread(self.sort_data) + + def sort_button_handler(self, button): + if button in {self.sort_ascend, self.sort_descend}: + self.settings.set_boolean("sort-ascend", self.sort_ascend.get_active()) + else: + self.settings.set_string("sort-mode", self.buttons_to_sort_modes[button]) + + self.adp.update_sort_mode() + self.ldp.update_sort_mode() + + def load_sort_settings(self): + mode = self.settings.get_string("sort-mode") + ascend = self.settings.get_boolean("sort-ascend") + self.sort_modes_to_buttons[mode].set_active(True) + (self.sort_ascend if ascend else self.sort_descend).set_active(True) + self.adp.update_sort_mode() + self.ldp.update_sort_mode() + + def view_change_handler(self, *args): + child = self.stack.get_visible_child() + if child.total_size == 0: + self.search_button.set_active(False) + self.search_button.set_sensitive(False) + self.select_button.set_active(False) + self.select_button.set_sensitive(False) + self.sort_button.set_active(False) + self.sort_button.set_sensitive(False) + self.search_entry.set_editable(False) + else: + self.search_button.set_sensitive(True) + self.select_button.set_sensitive(True) + self.sort_button.set_sensitive(True) + self.search_entry.set_editable(True) + + self.more_button.set_visible(child is self.ldp and self.bpt_is_applied) + self.install_button.set_visible(child is self.ldp and not self.bpt_is_applied) + self.trash_button.set_visible(child is self.adp or not self.bpt_is_applied) + + has_selected = len(child.selected_boxes) > 0 + self.copy_button.set_sensitive(has_selected) + self.trash_button.set_sensitive(has_selected) + self.install_button.set_sensitive(has_selected) + self.more_button.set_sensitive(has_selected) + + def select_toggle_handler(self, *args): + active = self.select_button.get_active() + self.adp.set_selection_mode(active) + self.ldp.set_selection_mode(active) + if not active: + self.copy_button.set_sensitive(False) + self.trash_button.set_sensitive(False) + self.install_button.set_sensitive(False) + self.more_button.set_sensitive(False) + + def select_all_handler(self, *args): + child = self.stack.get_visible_child() + child.select_all_handler() + + def copy_handler(self, *args): + child = self.stack.get_visible_child() + to_copy = "" + for box in child.selected_boxes: + to_copy += "\n" + box.data_path + + if len(to_copy) == 0: + self.toast_overlay.add_toast(ErrorToast(_("Could not copy paths"), _("No boxes were selected")).toast) + else: + HostInfo.clipboard.set(to_copy.replace("\n", "", 1)) + self.toast_overlay.add_toast(Adw.Toast(title=_("Copied paths"))) + + def selection_trash_handler(self, *args): + error = [None] + child = self.stack.get_visible_child() + + def thread(path): + cmd = ['gio', 'trash'] + path + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + properties_page = HostInfo.main_window.pages[HostInfo.main_window.packages_row].properties_page + properties_package = properties_page.package + if not properties_package is None: + properties_page.set_properties(properties_package, True) + + snapshot_list_page = HostInfo.main_window.pages[HostInfo.main_window.snapshots_row].list_page + snapshot_list_package = snapshot_list_page.package_or_folder + if not snapshot_list_package is None: + snapshot_list_page.set_snapshots(snapshot_list_package, True) + + except subprocess.CalledProcessError as cpe: + error[0] = cpe.stderr + except Exception as e: + error[0] = e + + def callback(*args): + self.start_loading() + self.end_loading() + if error[0]: + self.toast_overlay.add_toast(ErrorToast(_("Could not trash data"), str(error[0])).toast) + else: + self.toast_overlay.add_toast(Adw.Toast(title=_("Trashed data"))) + + def on_response(dialog, response): + self.is_trash_dialog_open = False + if response != "continue": + return + + to_trash = [] + for box in child.selected_boxes: + to_trash.append(box.data_path) + + if len(to_trash) == 0: + self.toast_overlay.add_toast(ErrorToast(_("Could not trash data"), _("No boxes were selected")).toast) + return + + self.select_button.set_active(False) + self.status_stack.set_visible_child(self.loading_view) + Gio.Task.new(None, None, callback).run_in_thread(lambda *_: thread(to_trash)) + + if len(child.selected_boxes) < 1 or self.is_trash_dialog_open: + return + + self.is_trash_dialog_open = True + dialog = Adw.AlertDialog(heading=_("Trash Data?"), body=_("Data will be sent to the trash")) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("continue", _("Continue")) + dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.connect("response", on_response) + dialog.present(ErrorToast.main_window) + + def breakpoint_handler(self, bpt, is_applied): + self.bpt_is_applied = is_applied + self.adp.label_box.set_orientation(Gtk.Orientation.VERTICAL if is_applied else Gtk.Orientation.HORIZONTAL) + self.ldp.label_box.set_orientation(Gtk.Orientation.VERTICAL if is_applied else Gtk.Orientation.HORIZONTAL) + child = self.stack.get_visible_child() + self.install_button.set_visible(child is self.ldp and not is_applied) + self.more_button.set_visible(child is self.ldp and is_applied) + self.trash_button.set_visible(child is self.adp or not is_applied) + + def install_handler(self, *args): + child = self.stack.get_visible_child() + package_names = [] + for box in child.selected_boxes: + package_names.append(box.subtitle) + + AttemptInstallDialog(package_names, lambda is_valid: self.select_button.set_active(not is_valid)) + + def more_menu_handler(self, listbox, row): + self.more_popover.popdown() + row = row.get_child() + match row: + case self.more_install: + self.install_handler() + case self.more_trash: + self.selection_trash_handler() + + def __init__(self, main_window, **kwargs): + super().__init__(**kwargs) + + # Extra Object Creation + self.__class__.instance = self + self.adp = DataSubpage(_("Active Data"), self, True, main_window) + self.ldp = DataSubpage(_("Leftover Data"), self, False, main_window) + self.data_flatpaks = [] + self.active_data = [] + self.leftover_data = [] + self.total_items = 0 + self.settings = Gio.Settings.new("io.github.flattool.Warehouse.data_page") + self.sort_modes_to_buttons = { + "name": self.sort_name, + "id": self.sort_id, + "size": self.sort_size, + } + self.buttons_to_sort_modes = {} + self.on_backspace_handler = self.selection_trash_handler + self.on_escape_handler = lambda *_: self.select_button.set_active(False) + + # Apply + for key, button in self.sort_modes_to_buttons.items(): + self.buttons_to_sort_modes[button] = key + + self.stack.add_titled_with_icon( + child=self.adp, + name="active", + title=_("Active Data"), + icon_name="file-manager-symbolic", + ) + self.stack.add_titled_with_icon( + child=self.ldp, + name="leftover", + title=_("Leftover Data"), + icon_name="folder-templates-symbolic", + ) + + # Connections + self.stack.connect("notify::visible-child", self.view_change_handler) + self.select_button.connect("toggled", self.select_toggle_handler) + self.select_all_button.connect("clicked", self.select_all_handler) + self.copy_button.connect("clicked", self.copy_handler) + self.trash_button.connect("clicked", self.selection_trash_handler) + self.install_button.connect("clicked", self.install_handler) + self.more_menu.connect("row-activated", self.more_menu_handler) + self.sort_ascend.connect("clicked", self.sort_button_handler) + self.sort_descend.connect("clicked", self.sort_button_handler) + self.sort_name.connect("clicked", self.sort_button_handler) + self.sort_id.connect("clicked", self.sort_button_handler) + self.sort_size.connect("clicked", self.sort_button_handler) + self.bpt.connect("apply", self.breakpoint_handler, True) + self.bpt.connect("unapply", self.breakpoint_handler, False) + + # Apply again + self.loading_view.set_content(LoadingStatus(_("Loading User Data"), _("This should only take a moment"))) + self.search_bar.set_key_capture_widget(main_window) + self.load_sort_settings() diff --git a/src/warehouse.gresource.xml b/src/warehouse.gresource.xml index abbf591..8102b6d 100644 --- a/src/warehouse.gresource.xml +++ b/src/warehouse.gresource.xml @@ -1,16 +1,34 @@ - ../data/ui/window.ui - ../data/ui/orphans.ui - ../data/ui/filter.ui - ../data/ui/remotes.ui - ../data/ui/downgrade.ui - ../data/ui/search_install.ui - ../data/ui/snapshots.ui - ../data/ui/properties.ui - ../data/style.css + ../data/style.css gtk/help-overlay.ui + gtk/loading_status.ui + gtk/app_row.ui + gtk/installation_chooser.ui + gtk/attempt_install_dialog.ui + main_window/window.ui + packages_page/packages_page.ui + packages_page/filters_page.ui + packages_page/uninstall_dialog.ui + properties_page/properties_page.ui + change_version_page/change_version_page.ui + user_data_page/data_box.ui + user_data_page/user_data_page.ui + user_data_page/data_subpage.ui + remotes_page/remotes_page.ui + remotes_page/remote_row.ui + remotes_page/add_remote_dialog.ui + snapshot_page/snapshot_page.ui + snapshot_page/snapshots_list_page.ui + snapshot_page/snapshot_box.ui + snapshot_page/new_snapshot_dialog.ui + install_page/file_install_dialog.ui + install_page/install_page.ui + install_page/result_row.ui + install_page/select_page.ui + install_page/results_page.ui + install_page/pending_page.ui @@ -37,5 +55,31 @@ ../data/icons/left-large-symbolic.svg ../data/icons/arrow-turn-left-down-symbolic.svg ../data/icons/arrow-circular-top-right-symbolic.svg + ../data/icons/dock-left-symbolic.svg + ../data/icons/server-pick-symbolic.svg + ../data/icons/file-manager-symbolic.svg + ../data/icons/snapshots-alt-symbolic.svg + ../data/icons/arrow-pointing-at-line-down-symbolic.svg + ../data/icons/loupe-large-symbolic.svg + ../data/icons/folder-open-symbolic.svg + ../data/icons/padlock2-symbolic.svg + ../data/icons/pin-symbolic.svg + ../data/icons/pin-small-symbolic.svg + ../data/icons/error-small-symbolic.svg + ../data/icons/copy-symbolic.svg + ../data/icons/double-ended-arrows-vertical-symbolic.svg + ../data/icons/vertical-arrows-long-symbolic.svg + ../data/icons/dot-symbolic.svg + ../data/icons/folder-templates-symbolic.svg + ../data/icons/view-sort-ascending-symbolic.svg + ../data/icons/view-sort-descending-symbolic.svg + ../data/icons/font-x-generic-symbolic.svg + ../data/icons/tag-outline-symbolic.svg + ../data/icons/harddisk-symbolic.svg + ../data/icons/arrow-turn-down-right-symbolic.svg + ../data/icons/minus-large-symbolic.svg + ../data/icons/view-list-bullet-symbolic.svg + ../data/icons/list-remove-all-symbolic.svg + ../data/icons/edit-symbolic.svg diff --git a/src/warehouse.in b/src/warehouse.in index fdff899..7590638 100755 --- a/src/warehouse.in +++ b/src/warehouse.in @@ -42,5 +42,5 @@ if __name__ == '__main__': resource = Gio.Resource.load(os.path.join(pkgdatadir, 'warehouse.gresource')) resource._register() - from flattool_gui import main + from Warehouse import main sys.exit(main.main(VERSION)) diff --git a/src/window.py b/src/window.py deleted file mode 100644 index 6e54360..0000000 --- a/src/window.py +++ /dev/null @@ -1,970 +0,0 @@ -# window.py -# -# Copyright 2023 Heliguy -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, version 3 of the License only. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -# SPDX-License-Identifier: GPL-3.0-only - -import os -import pathlib -import subprocess -import re -import time - -from gi.repository import Adw, Gdk, Gio, GLib, Gtk -# from .properties_window import PropertiesWindow -from .filter_window import FilterWindow -from .common import myUtils -from .remotes_window import RemotesWindow -from .downgrade_window import DowngradeWindow -from .snapshots_window import SnapshotsWindow -from .const import Config - -from .app_row_widget import AppRow - - -@Gtk.Template(resource_path="/io/github/flattool/Warehouse/../data/ui/window.ui") -class WarehouseWindow(Adw.ApplicationWindow): - __gtype_name__ = "WarehouseWindow" - main_window_title = "Warehouse" - flatpaks_list_box = Gtk.Template.Child() - search_entry = Gtk.Template.Child() - search_button = Gtk.Template.Child() - search_bar = Gtk.Template.Child() - toast_overlay = Gtk.Template.Child() - no_flatpaks = Gtk.Template.Child() - no_results = Gtk.Template.Child() - main_stack = Gtk.Template.Child() - batch_mode_button = Gtk.Template.Child() - batch_mode_bar = Gtk.Template.Child() - batch_select_all_button = Gtk.Template.Child() - batch_uninstall_button = Gtk.Template.Child() - batch_clean_button = Gtk.Template.Child() - batch_copy_button = Gtk.Template.Child() - batch_snapshot_button = Gtk.Template.Child() - main_box = Gtk.Template.Child() - main_overlay = Gtk.Template.Child() - main_toolbar_view = Gtk.Template.Child() - filter_button = Gtk.Template.Child() - scrolled_window = Gtk.Template.Child() - main_menu = Gtk.Template.Child() - installing = Gtk.Template.Child() - uninstalling = Gtk.Template.Child() - snapshotting = Gtk.Template.Child() - loading_flatpaks = Gtk.Template.Child() - no_matches = Gtk.Template.Child() - reset_filters_button = Gtk.Template.Child() - uninstalling_status = Gtk.Template.Child() - refreshing = Gtk.Template.Child() - - main_progress_bar = Gtk.ProgressBar(visible=False, can_target=False) - main_progress_bar.add_css_class("osd") - clipboard = Gdk.Display.get_default().get_clipboard() - host_home = str(pathlib.Path.home()) - user_data_path = host_home + "/.var/app/" - in_batch_mode = False - should_select_all = False - host_flatpaks = None - install_success = True - no_close = None - re_get_flatpaks = False - currently_uninstalling = False - is_result = False - is_empty = False - total_selected = 0 - - def filter_func(self, row): - if (self.search_entry.get_text().lower() in row.get_title().lower()) or ( - self.search_entry.get_text().lower() in row.get_subtitle().lower() - ): - self.is_result = True - return True - - def uninstall_buttons_enable(self, should_enable): - if self.currently_uninstalling: - return - if not should_enable: - self.batch_uninstall_button.set_sensitive(False) - - def uninstall_flatpak_callback(self, _a, _b): - self.currently_uninstalling = False - self.refresh_list_of_flatpaks(_a) - self.main_toolbar_view.set_sensitive(True) - self.disconnect(self.no_close) - self.uninstall_buttons_enable(True) - self.main_stack.set_visible_child(self.main_box) - self.search_button.set_sensitive(True) - self.batch_actions_enable(False) - if self.my_utils.uninstall_success: - self.refresh_list_of_flatpaks(self) - self.toast_overlay.add_toast(Adw.Toast.new(_("Uninstalled successfully"))) - else: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not uninstall some apps")) - ) - - def uninstall_flatpak_thread(self, ref_arr, id_arr, type_arr, should_trash): - self.my_utils.uninstall_flatpak( - ref_arr, - type_arr, - should_trash, - self.main_progress_bar, - self.uninstalling_status, - ) - - def uninstall_flatpak(self, should_trash): - ref_arr = [] - id_arr = [] - type_arr = [] - self.currently_uninstalling = True - i = 0 - while self.flatpaks_list_box.get_row_at_index(i) != None: - current = self.flatpaks_list_box.get_row_at_index(i) - if current.tickbox.get_active() == True: - ref_arr.append(current.app_ref) - id_arr.append(current.app_id) - type_arr.append(current.install_type) - i += 1 - self.set_title(self.main_window_title) - task = Gio.Task.new(None, None, self.uninstall_flatpak_callback) - task.run_in_thread( - lambda _task, _obj, _data, _cancellable, ref_arr=ref_arr, id_arr=id_arr, type_arr=type_arr, should_trash=should_trash: self.uninstall_flatpak_thread( - ref_arr, id_arr, type_arr, should_trash - ) - ) - - def batch_uninstall_button_handler(self, _widget): - has_user_data = False - - def batch_uninstall_response(_idk, response_id, _widget): - if response_id == "cancel": - return 1 - - try: - should_trash = trash_check.get_active() - except: - should_trash = False - - self.uninstall_buttons_enable(False) - - self.no_close = self.connect( - "close-request", lambda event: True - ) # Make window unable to close - self.main_stack.set_visible_child(self.uninstalling) - self.search_button.set_sensitive(False) - self.uninstall_flatpak(should_trash) - - # Create Widgets - dialog = Adw.AlertDialog.new( - _("Uninstall Selected Apps?"), - _("It will not be possible to use these apps after removal."), - ) - - # Check to see if at least one app in the list has user data - i = 0 - while True: - current = self.flatpaks_list_box.get_row_at_index(i) - i += 1 - if current == None: - break - if current.tickbox.get_active() and os.path.exists( - f"{self.user_data_path}{current.app_id}" - ): - has_user_data = True - break - - if has_user_data: - # Create Widgets - options_box = Gtk.Box(orientation="vertical") - header = Gtk.Label( - label=_("App Settings & Data"), halign="start", margin_top=10 - ) - options_list = Gtk.ListBox(selection_mode="none", margin_top=15) - keep_data = Adw.ActionRow( - title=_("Keep"), - subtitle=_("Allow restoring these apps' settings and content"), - ) - trash_data = Adw.ActionRow( - title=_("Trash"), - subtitle=_("Send these apps' settings and content to the trash"), - ) - keep_check = Gtk.CheckButton() - trash_check = Gtk.CheckButton() - - # Apply Widgets - keep_data.add_prefix(keep_check) - keep_data.set_activatable_widget(keep_check) - trash_data.add_prefix(trash_check) - trash_data.set_activatable_widget(trash_check) - keep_check.set_group(trash_check) - options_list.append(keep_data) - options_list.append(trash_data) - options_box.append(header) - options_box.append(options_list) - dialog.set_extra_child(options_box) - - # Calls - keep_check.set_active(True) - options_list.add_css_class("boxed-list") - header.add_css_class("heading") - header.add_css_class("h4") - - # Connections - dialog.connect("response", batch_uninstall_response, dialog.choose_finish) - - # Calls - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Uninstall")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.present(self) - - def uninstall_button_handler(self, row, name, ref, id): - if self.currently_uninstalling: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Cannot uninstall while already uninstalling")) - ) - return - - def uninstall_response(_idk, response_id, _widget): - if response_id == "cancel": - return 1 - - try: - should_trash = trash_check.get_active() - except: - should_trash = False - - if response_id == "purge": - should_trash = True - - self.uninstall_buttons_enable(False) - - self.no_close = self.connect( - "close-request", lambda event: True - ) # Make window unable to close - self.main_stack.set_visible_child(self.uninstalling) - self.search_button.set_sensitive(False) - self.uninstall_flatpak(should_trash) - - row.tickbox.set_active(True) - - # Create Widgets - dialog = Adw.AlertDialog.new( - _("Uninstall {}?").format(name), - _("It will not be possible to use {} after removal.").format(name), - ) - - if os.path.exists(f"{self.user_data_path}{id}"): - # Create Widgets for Trash - options_box = Gtk.Box(orientation="vertical") - header = Gtk.Label( - label=_("App Settings & Data"), halign="start", margin_top=10 - ) - options_list = Gtk.ListBox(selection_mode="none", margin_top=15) - keep_data = Adw.ActionRow( - title=_("Keep"), - subtitle=_("Allow restoring this app's settings and content"), - ) - trash_data = Adw.ActionRow( - title=_("Trash"), - subtitle=_("Send this app's settings and content to the trash"), - ) - keep_check = Gtk.CheckButton(active=True) - trash_check = Gtk.CheckButton() - - # Apply Widgets for Trash - keep_data.add_prefix(keep_check) - keep_data.set_activatable_widget(keep_check) - trash_data.add_prefix(trash_check) - trash_data.set_activatable_widget(trash_check) - keep_check.set_group(trash_check) - dialog.set_extra_child(options_box) - options_list.append(keep_data) - options_list.append(trash_data) - options_box.append(header) - options_box.append(options_list) - options_list.add_css_class("boxed-list") - - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Uninstall")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.connect("response", uninstall_response, dialog.choose_finish) - dialog.present(self) - - def window_set_empty(self, is_empty): - self.batch_mode_button.set_sensitive(not is_empty) - self.search_button.set_sensitive(not is_empty) - self.filter_button.set_sensitive(not is_empty) - self.is_empty = is_empty - - if is_empty: - self.batch_mode_button.set_active(False) - self.main_stack.set_visible_child(self.no_flatpaks) - self.search_button.set_sensitive(False) - else: - self.main_stack.set_visible_child(self.main_box) - self.search_button.set_sensitive(True) - - def create_row(self, index): - row = AppRow(self, self.host_flatpaks, index) - if row.app_id == "io.github.flattool.Warehouse": - row.tickbox.set_sensitive(False) - self.flatpaks_list_box.insert(row, index) - - def generate_list_of_flatpaks(self): - self.host_flatpaks = self.my_utils.get_host_flatpaks() - self.dependent_runtimes = self.my_utils.get_dependent_runtimes() - self.set_title(self.main_window_title) - self.eol_list = [] - self.system_mask_list = self.my_utils.get_host_masks("system") - self.user_mask_list = self.my_utils.get_host_masks("user") - - for index in range(len(self.host_flatpaks)): - try: - if "eol" in self.host_flatpaks[index][12]: - self.eol_list.append(self.host_flatpaks[index][8]) - except: - print("Could not find EOL") - - for index in range(len(self.host_flatpaks)): - self.create_row(index) - - # self.windowSetEmpty(not self.flatpaks_list_box.get_row_at_index(0)) - self.batch_actions_enable(False) - self.main_stack.set_visible_child(self.main_box) - self.apply_filter() - - # Stop list window from opening with the list at the bottom by focusing the first visible row item - for index in range(len(self.host_flatpaks)): - if self.flatpaks_list_box.get_row_at_index(index).is_visible(): - self.flatpaks_list_box.get_row_at_index(index).grab_focus() - break - - def refresh_list_of_flatpaks(self, widget): - if self.currently_uninstalling: - return - - # I hate this so much... - def callback(*args): - self.flatpaks_list_box.remove_all() - self.generate_list_of_flatpaks() - self.batch_mode_button.set_active(False) - self.total_selected = 0 - - def runner(*args): - import time - time.sleep(0.2) - - self.main_stack.set_visible_child(self.refreshing) - task = Gio.Task.new(None, None, callback) - task.run_in_thread(runner) - - def reset_filters(self): - settings = Gio.Settings.new("io.github.flattool.Warehouse.filter") - for key in settings.list_keys(): - settings.reset(key) - self.apply_filter() - - def apply_filter(self): - self.batch_mode_button.set_active(False) - settings = Gio.Settings.new("io.github.flattool.Warehouse.filter") - show_apps = settings.get_boolean("show-apps") - show_runtimes = settings.get_boolean("show-runtimes") - remotes_list = settings.get_string("remotes-list").split(",") - runtimes_list = settings.get_string("runtimes-list").split(",") - total_visible = 0 - i = 0 - while self.flatpaks_list_box.get_row_at_index(i) != None: - current = self.flatpaks_list_box.get_row_at_index(i) - id = current.app_id - dependant = current.dependent_runtime - remote = f"{current.origin_remote}<>{current.install_type};" - is_runtime = current.is_runtime - - visible = True - if (not show_apps) and (not is_runtime): - visible = False - - if (not show_runtimes) and is_runtime: - visible = False - - if (not "all" in remotes_list) and (not remote in remotes_list): - visible = False - - if (not "all" in runtimes_list) and (not dependant in runtimes_list): - visible = False - - current.set_is_visible(visible) - total_visible += visible - i += 1 - if (total_visible == 0) or (runtimes_list != ["all"] and show_runtimes): - self.window_set_empty(True) - self.main_stack.set_visible_child(self.no_matches) - else: - self.window_set_empty(False) - self.main_stack.set_visible_child(self.main_box) - - def open_data_folder(self, path): - try: - Gio.AppInfo.launch_default_for_uri(f"file://{path}", None) - except GLib.GError: - self.toast_overlay.add_toast(Adw.Toast.new(_("Could not open folder"))) - - def trash_data(self, name, id, index): - def on_continue(dialog, response): - if response == "cancel": - return - result = self.my_utils.trash_folder(f"{self.user_data_path}{id}") - if result != 0: - self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not trash user data")) - ) - return - self.lookup_action(f"open-data{index}").set_enabled(False) - self.lookup_action(f"trash{index}").set_enabled(False) - self.toast_overlay.add_toast(Adw.Toast.new(_("Trashed user data"))) - - dialog = Adw.AlertDialog.new( - _("Send {}'s User Data to the Trash?").format(name) - ) - dialog.set_body( - _("Your files and data for this app will be sent to the trash.") - ) - dialog.add_response("cancel", _("Cancel")) - dialog.set_close_response("cancel") - dialog.add_response("continue", _("Trash Data")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.connect("response", on_continue) - dialog.present(self) - - def mask_flatpak(self, row): - is_masked = ( - row.mask_label.get_visible() - ) # Check the visibility of the mask label to see if the flatpak is masked - result = [] - - def callback(): - if result[0] == 1: - self.toast_overlay.add_toast( - Adw.Toast.new( - _("Could not disable updates for {}").format(row.app_name) - ) - ) - return - row.set_masked(not is_masked) - self.lookup_action(f"mask{row.index}").set_enabled(is_masked) - self.lookup_action(f"unmask{row.index}").set_enabled(not is_masked) - - def on_continue(dialog, response): - if response == "cancel": - return - task = Gio.Task.new(None, None, lambda *_: callback()) - task.run_in_thread( - lambda *_: result.append( - self.my_utils.mask_flatpak(row.app_id, row.install_type, is_masked) - ) - ) - - if is_masked: - on_continue(self, None) - else: - dialog = Adw.AlertDialog.new( - _("Disable Updates for {}?").format(row.app_name) - ) - dialog.set_body( - _( - "This will mask {} ensuring it will never recieve any feature or security updates." - ).format(row.app_name) - ) - dialog.add_response("cancel", _("Cancel")) - dialog.set_close_response("cancel") - dialog.add_response("continue", _("Disable Updates")) - dialog.connect("response", on_continue) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.present(self) - - def pin_flatpak(self, row): - def thread(*args): - command = f"flatpak-spawn --host flatpak pin --{row.install_type} runtime/{row.app_ref}" - if row.is_pinned: - command += " --remove" - response = subprocess.run( - command, - capture_output=True, - text=True, - shell=True - ).stderr - if response != "" and row.is_pinned: - GLib.idle_add(self.toast_overlay.add_toast(Adw.Toast.new(_("Could not enable auto removal")))) - return - elif response != "": - GLib.idle_add(self.toast_overlay.add_toast(Adw.Toast.new(_("Could not disable auto removal")))) - return - row.is_pinned = not row.is_pinned - GLib.idle_add(lambda *_, row=row: self.lookup_action(f"pin{row.index}").set_enabled(not row.is_pinned)) - GLib.idle_add(lambda *_, row=row: self.lookup_action(f"unpin{row.index}").set_enabled(row.is_pinned)) - GLib.idle_add(lambda *_, row=row: row.pin_label.set_visible(row.is_pinned)) - GLib.idle_add(lambda *_, row=row: row.info_button_show_or_hide()) - - def callback(*args): - print("done") - - def on_continue(dialog, response): - if response == "cancel": - return - - task = Gio.Task.new(None, None, None) - task.run_in_thread(thread) - - if row.is_pinned: - on_continue(self, None) - else: - dialog = Adw.AlertDialog.new( - _( - "Disable Automatic Removal for {}?" - ).format(row.app_name), - _( - "This will pin {} ensuring it well never be removed automatically, even if no app depends on it." - ).format(row.app_name), - ) - dialog.add_response("cancel", _("Cancel")) - dialog.set_close_response("cancel") - dialog.connect("response", on_continue) - dialog.add_response("continue", _("Disable Auto Removal")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.SUGGESTED) - dialog.present(self) - - def copy_item(self, to_copy, to_toast=None): - self.clipboard.set(to_copy) - if to_toast: - self.toast_overlay.add_toast(Adw.Toast.new(to_toast)) - - def run_callback(self, _a, _b): - if not self.my_utils.run_app_error: - return - - error = self.my_utils.run_app_error_message - dialog = Adw.AlertDialog.new(_("Could not Run App"), error) - copy_button = Gtk.Button( - label=_("Copy"), halign=Gtk.Align.CENTER, margin_top=12 - ) - copy_button.add_css_class("pill") - copy_button.add_css_class("suggested-action") - copy_button.connect("clicked", lambda *_: self.clipboard.set(error)) - dialog.set_extra_child(copy_button) - dialog.add_response("ok", _("OK")) - dialog.set_close_response("ok") - dialog.present(self) - - def run_app_thread(self, ref, to_toast=None): - self.run_app_error = False - task = Gio.Task.new(None, None, self.run_callback) - task.run_in_thread(lambda *_: self.my_utils.run_app(ref)) - if to_toast: - self.toast_overlay.add_toast(Adw.Toast.new(to_toast)) - - def batch_mode_handler(self, widget): - batch_mode = widget.get_active() - i = 0 - while self.flatpaks_list_box.get_row_at_index(i) != None: - current = self.flatpaks_list_box.get_row_at_index(i) - current.set_selectable(batch_mode) - i += 1 - self.in_batch_mode = batch_mode - self.batch_mode_bar.set_revealed(batch_mode) - - if not widget.get_active(): - self.batch_select_all_button.set_active(False) - - def key_handler(self, controller, keyval, keycode, state): - if keyval == Gdk.KEY_w and state == Gdk.ModifierType.CONTROL_MASK: - self.close() - if keyval == Gdk.KEY_Escape: - self.batch_mode_button.set_active(False) - - def batch_actions_enable(self, should_enable): - self.batch_copy_button.set_sensitive(should_enable) - self.batch_clean_button.set_sensitive(should_enable) - self.batch_snapshot_button.set_sensitive(should_enable) - if not self.currently_uninstalling: - self.batch_uninstall_button.set_sensitive(should_enable) - - def on_batch_clean_response(self, dialog, response, _a): - if response == "cancel": - return - i = 0 - trashReturnCodes = 0 - while True: - current = self.flatpaks_list_box.get_row_at_index(i) - i += 1 - if current == None: - break - if current.tickbox.get_active() == False: - continue - trash = self.my_utils.trash_folder(f"{self.user_data_path}{current.app_id}") - if trash == 1: - self.toast_overlay.add_toast( - Adw.Toast.new(_("{} has no data to trash").format(current.app_name)) - ) - continue - if trash == 2: - self.toast_overlay.add_toast( - Adw.Toast.new( - _("Could not trash {}'s data").format(current.app_name) - ) - ) - continue - self.lookup_action(f"open-data{current.index}").set_enabled( - False - ) # Disable the Open User Data dropdown option when the data was deleted - self.lookup_action(f"trash{current.index}").set_enabled( - False - ) # Disable the Trash User Data dropdown option when the data was deleted - self.batch_actions_enable(False) - self.batch_mode_button.set_active(False) - - def batch_clean_handler(self, widget): - dialog = Adw.AlertDialog.new( - _("Trash Selected Apps' User Data?"), - _("Your files and data for these apps will be sent to the trash."), - ) - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Trash Data")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.connect("response", self.on_batch_clean_response, dialog.choose_finish) - dialog.present(self) - - def select_all_handler(self, widget): - self.set_select_all(widget.get_active()) - - def batch_snapshot_handler(self, widget): - def batch_snapshot_response(dialog, response, _a): - if response == "cancel": - return - i = 0 - snapshots_path = ( - self.host_home - + "/.var/app/io.github.flattool.Warehouse/data/Snapshots/" - ) - snapshot_arr = [] - app_ver_arr = [] - app_data_arr = [] - epoch = int(time.time()) - self.no_close = self.connect( - "close-request", lambda event: True - ) # Make window unable to close - while self.flatpaks_list_box.get_row_at_index(i) != None: - current = self.flatpaks_list_box.get_row_at_index(i) - i += 1 - if current.tickbox.get_active() == False: - continue - if not os.path.exists(f"{self.user_data_path}{current.app_id}"): - continue - snapshot_arr.append(snapshots_path + current.app_id + "/") - app_ver_arr.append(current.app_version) - app_data_arr.append(f"{self.user_data_path}{current.app_id}") - - def thread(): - capture = self.my_utils.snapshot_apps( - epoch, - snapshot_arr, - app_ver_arr, - app_data_arr, - self.main_progress_bar, - ) - if capture != 0: - GLib.idle_add( - lambda *_: self.toast_overlay.add_toast( - Adw.Toast.new(_("Could not snapshot some apps")) - ) - ) - - def callback(*args): - self.main_stack.set_visible_child(self.main_box) - self.disconnect(self.no_close) - self.search_button.set_sensitive(True) - - self.search_button.set_sensitive(False) - self.batch_actions_enable(False) - self.batch_mode_button.set_active(False) - self.main_stack.set_visible_child(self.snapshotting) - task = Gio.Task.new(None, None, callback) - task.run_in_thread(lambda *_: thread()) - - dialog = Adw.AlertDialog.new( - _("Create Snapshots?"), - _( - "Snapshots are backups of the app's user data. They can be reapplied at any time. This could take a while." - ), - ) - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Create Snapshots")) - dialog.connect("response", batch_snapshot_response, dialog.choose_finish) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.SUGGESTED) - dialog.present(self) - - def set_select_all(self, should_select_all): - i = 0 - while self.flatpaks_list_box.get_row_at_index(i) != None: - current = self.flatpaks_list_box.get_row_at_index(i) - if (current.get_visible() == True) and ( - current.app_id != "io.github.flattool.Warehouse" - ): - current.tickbox.set_active(should_select_all) - i += 1 - - def row_select_handler(self, tickbox): - if tickbox.get_active() == True: - self.total_selected += 1 - else: - self.total_selected -= 1 - - if self.total_selected == 0: - buttons_enable = False - self.set_title(self.main_window_title) - self.batch_actions_enable(False) - else: - self.set_title(f"{self.total_selected} Selected") - self.batch_actions_enable(True) - - def create_action(self, name, callback, shortcuts=None): - """Add a window action. - - Args: - name: the name of the action - callback: the function to be called when the action is - activated - shortcuts: an optional list of accelerators - """ - action = Gio.SimpleAction.new(name, None) - action.connect("activate", callback) - self.add_action(action) - if shortcuts: - self.set_accels_for_action(f"app.{name}", shortcuts) - - def copy_names(self, widget, _a): - to_copy = "" - i = 0 - while True: - current = self.flatpaks_list_box.get_row_at_index(i) - i += 1 - if current == None: - break - if current.tickbox.get_active(): - to_copy += f"{current.app_name}\n" - self.clipboard.set(to_copy) - self.toast_overlay.add_toast(Adw.Toast.new(_("Copied selected app names"))) - self.batch_actions_enable(False) - self.batch_mode_button.set_active(False) - - def copy_IDs(self, widget, _a): - to_copy = "" - i = 0 - while True: - current = self.flatpaks_list_box.get_row_at_index(i) - i += 1 - if current == None: - break - if current.tickbox.get_active(): - to_copy += f"{current.app_id}\n" - self.clipboard.set(to_copy) - self.toast_overlay.add_toast(Adw.Toast.new(_("Copied selected app IDs"))) - self.batch_actions_enable(False) - self.batch_mode_button.set_active(False) - - def copy_refs(self, widget, _a): - to_copy = "" - i = 0 - while True: - current = self.flatpaks_list_box.get_row_at_index(i) - i += 1 - if current == None: - break - if current.tickbox.get_active(): - to_copy += f"{current.app_ref}\n" - self.clipboard.set(to_copy) - self.toast_overlay.add_toast(Adw.Toast.new(_("Copied selected app refs"))) - self.batch_actions_enable(False) - self.batch_mode_button.set_active(False) - - def install_callback(self, _a, _b): - self.main_stack.set_visible_child(self.main_box) - self.search_button.set_sensitive(True) - if self.my_utils.install_success: - self.refresh_list_of_flatpaks(self) - self.toast_overlay.add_toast(Adw.Toast.new(_("Installed successfully"))) - else: - self.toast_overlay.add_toast(Adw.Toast.new(_("Could not install app"))) - - def install_thread(self, filepath, user_or_system): - self.my_utils.install_flatpak( - [filepath], None, user_or_system, self.main_progress_bar - ) - - def install_file(self, filepath): - def response(dialog, response, _a): - if response == "cancel": - return - - self.main_stack.set_visible_child(self.installing) - self.search_button.set_sensitive(False) - user_or_system = "user" - if system_check.get_active(): - user_or_system = "system" - - task = Gio.Task.new(None, None, self.install_callback) - task.run_in_thread(lambda *_: self.install_thread(filepath, user_or_system)) - - name = filepath.split("/") - name = name[len(name) - 1] - - dialog = Adw.AlertDialog.new(_("Install {}?").format(name)) - dialog.set_close_response("cancel") - dialog.add_response("cancel", _("Cancel")) - dialog.add_response("continue", _("Install")) - dialog.set_response_appearance("continue", Adw.ResponseAppearance.SUGGESTED) - dialog.connect("response", response, dialog.choose_finish) - - # Create Widgets - options_box = Gtk.Box(orientation="vertical") - options_list = Gtk.ListBox(selection_mode="none", margin_top=15) - user_row = Adw.ActionRow( - title=_("User"), subtitle=_("The app will be available to only you") - ) - system_row = Adw.ActionRow( - title=_("System"), - subtitle=_("The app will be available to every user on the system"), - ) - user_check = Gtk.CheckButton() - system_check = Gtk.CheckButton() - - # Apply Widgets - user_row.add_prefix(user_check) - user_row.set_activatable_widget(user_check) - system_row.add_prefix(system_check) - system_row.set_activatable_widget(system_check) - user_check.set_group(system_check) - options_list.append(user_row) - options_list.append(system_row) - options_box.append(options_list) - dialog.set_extra_child(options_box) - - # Calls - user_check.set_active(True) - options_list.add_css_class("boxed-list") - dialog.present(self) - - def drop_callback(self, target, _x, _y, _data): - filepath = target.get_value().get_path() - if filepath.endswith(".flatpak") or filepath.endswith(".flatpakref"): - self.install_file(filepath) - elif filepath.endswith(".flatpakrepo"): - remotes_window = RemotesWindow(self) - remotes_window.present() - remotes_window.add_remote_file(filepath) - else: - self.toast_overlay.add_toast(Adw.Toast.new(_("File type not supported"))) - - def on_invalidate(self, row): - if self.is_empty: - self.batch_mode_button.set_active(False) - self.main_stack.set_visible_child(self.no_flatpaks) - self.search_button.set_sensitive(False) - else: - self.main_stack.set_visible_child(self.main_box) - self.search_button.set_sensitive(True) - - self.is_result = False - self.flatpaks_list_box.invalidate_filter() - if self.is_result == False: - self.main_stack.set_visible_child(self.no_results) - self.search_button.set_sensitive(False) - - def on_change(self, prop, prop2): - if self.search_bar.get_search_mode() == False: - if self.is_empty: - self.batch_mode_button.set_active(False) - self.main_stack.set_visible_child(self.no_flatpaks) - self.search_button.set_sensitive(False) - else: - self.main_stack.set_visible_child(self.main_box) - self.search_button.set_sensitive(True) - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.my_utils = myUtils(self) - self.set_size_request(360, 360) - self.settings = Gio.Settings.new("io.github.flattool.Warehouse") - self.settings.bind( - "window-width", self, "default-width", Gio.SettingsBindFlags.DEFAULT - ) - self.settings.bind( - "window-height", self, "default-height", Gio.SettingsBindFlags.DEFAULT - ) - self.settings.bind( - "is-maximized", self, "maximized", Gio.SettingsBindFlags.DEFAULT - ) - self.settings.bind( - "is-fullscreen", self, "fullscreened", Gio.SettingsBindFlags.DEFAULT - ) - - self.system_pins = self.my_utils.get_host_system_pins() - self.user_pins = self.my_utils.get_host_user_pins() - - self.new_env = dict(os.environ) - self.new_env["LC_ALL"] = "C" - - if self.host_flatpaks == [["", ""]]: - self.window_set_empty(True) - return - - self.flatpaks_list_box.set_filter_func(self.filter_func) - - task = Gio.Task() - task.run_in_thread( - lambda *_: GLib.idle_add(lambda *_: self.generate_list_of_flatpaks()) - ) - - self.search_entry.connect("search-changed", self.on_invalidate) - self.search_bar.connect_entry(self.search_entry) - self.search_bar.connect("notify", self.on_change) - self.filter_button.connect("clicked", lambda *_: FilterWindow(self)) - self.batch_mode_button.connect("toggled", self.batch_mode_handler) - self.batch_clean_button.connect("clicked", self.batch_clean_handler) - self.batch_uninstall_button.connect( - "clicked", self.batch_uninstall_button_handler - ) - self.batch_select_all_button.connect("clicked", self.select_all_handler) - self.batch_snapshot_button.connect("clicked", self.batch_snapshot_handler) - self.reset_filters_button.connect("clicked", lambda *_: self.reset_filters()) - self.batch_actions_enable(False) - event_controller = Gtk.EventControllerKey() - event_controller.connect("key-pressed", self.key_handler) - self.add_controller(event_controller) - self.main_overlay.add_overlay(self.main_progress_bar) - - self.create_action("copy-names", self.copy_names) - self.create_action("copy-ids", self.copy_IDs) - self.create_action("copy-refs", self.copy_refs) - - file_drop = Gtk.DropTarget.new(Gio.File, Gdk.DragAction.COPY) - file_drop.connect("drop", self.drop_callback) - self.scrolled_window.add_controller(file_drop) - - if Config.DEVEL: - self.add_css_class("devel")