├── .gitignore ├── README.md ├── build-utils └── deps-verifier.py ├── com.github.essmehdi.Flow.yml ├── extension ├── com.github.essmehdi.flow.json ├── host.py.in ├── meson.build └── src │ ├── catcher.js │ ├── links.js │ └── manifest.json ├── flow ├── __init__.py ├── core │ ├── __init__.py │ ├── controller.py │ ├── download.py │ ├── meson.build │ ├── settings.py │ └── status_manager.py ├── load.py.in ├── main.py ├── meson.build ├── ui │ ├── __init__.py │ ├── about.py │ ├── browser_wait.py │ ├── download_edit.py │ ├── download_item.py │ ├── file_chooser_button.py │ ├── meson.build │ ├── preferences │ │ ├── __init__.py │ │ ├── category_editor.py │ │ ├── category_row.py │ │ ├── meson.build │ │ └── window.py │ ├── shortcuts.py │ └── url_prompt.py └── utils │ ├── __init__.py │ ├── app_info.py │ ├── meson.build │ ├── misc.py │ ├── notifier.py │ └── toaster.py ├── meson.build ├── meson_options.txt ├── po ├── LINGUAS ├── POTFILES ├── ar.po └── meson.build ├── resources ├── com.github.essmehdi.flow.desktop.in.in ├── com.github.essmehdi.flow.gschema.xml ├── flow.gresource.xml ├── icons │ ├── com.github.essmehdi.flow.devel.svg │ ├── com.github.essmehdi.flow.svg │ └── meson.build ├── layout │ ├── about.ui │ ├── browser_wait.ui │ ├── download_edit.ui │ ├── download_item.ui │ ├── empty.ui │ ├── file_chooser_button.ui │ ├── main.ui │ ├── preferences │ │ ├── add_row.ui │ │ ├── category_editor.ui │ │ ├── category_row.ui │ │ └── window.ui │ ├── shortcuts.ui │ └── url_prompt.ui ├── meson.build └── style.css └── screenshots └── main.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # Build folders 148 | builddir/ 149 | 150 | # Editors config 151 | .idea/ 152 | .vscode/ 153 | .buildconfig 154 | 155 | # Flatpak 156 | .flatpak-builder 157 | 158 | # Extension builds 159 | extension/src/web-ext-artifacts 160 | 161 | os 162 | sys 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flow - The download manager for GNOME 2 | 3 | ![](screenshots/main.png) 4 | 5 | A utility application to automatically organize and keep track of your downloads designed for GNOME. This project is still in development. 6 | 7 | This app aims to be an alternative for Internet Download Manager on Linux. 8 | 9 | This download manager features basic download with cURL, auto-sorting based on file extension and resuming with new link for broken downloads. More features are coming. 10 | 11 | ## Browser extension 12 | 13 | For now, it is only available for Firefox. 14 | 15 | You can find the extension for Firefox [here](https://addons.mozilla.org/en-US/firefox/addon/flow-intercepter/). 16 | 17 | ## Build from source 18 | 19 | **Linux dependencies** `python3`, `meson`, `gtk4`, `libadwaita-1` and `libcurl`. 20 | 21 | **Python dependencies** `validators` and `pycurl`. 22 | 23 | To build from source & install, clone this repository and build the project with Meson: 24 | 25 | ```shell 26 | git clone https://github.com/essmehdi/flow.git 27 | cd flow 28 | meson build --prefix=/usr/local 29 | sudo ninja -C build install 30 | ``` -------------------------------------------------------------------------------- /build-utils/deps-verifier.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | try: 4 | import pycurl 5 | except ImportError: 6 | print("Make sure you have installed 'pycurl' for Python.") 7 | sys.exit(1) 8 | 9 | try: 10 | import validators 11 | except ImportError: 12 | print("Make sure you have installed 'validators' for Python.") 13 | sys.exit(1) 14 | -------------------------------------------------------------------------------- /com.github.essmehdi.Flow.yml: -------------------------------------------------------------------------------- 1 | app-id: com.github.essmehdi.Flow 2 | runtime: org.gnome.Platform 3 | runtime-version: "42" 4 | sdk: org.gnome.Sdk 5 | command: flow 6 | modules: 7 | - name: flow 8 | builddir: true 9 | buildsystem: meson 10 | sources: 11 | - type: dir 12 | name: . -------------------------------------------------------------------------------- /extension/com.github.essmehdi.flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "com.github.essmehdi.flow", 3 | "description": "Native messaging for Flow", 4 | "path": "/usr/local/bin/flow-connector", 5 | "type": "stdio", 6 | "allowed_extensions": [ "flowcatcher.essmehdi@github.com" ] 7 | } -------------------------------------------------------------------------------- /extension/host.py.in: -------------------------------------------------------------------------------- 1 | #!@PYTHON@ 2 | 3 | import json 4 | import subprocess 5 | import sys 6 | import struct 7 | 8 | def get_message(): 9 | raw_length = sys.stdin.buffer.read(4) 10 | if not raw_length: 11 | sys.exit(0) 12 | message_length = struct.unpack('=I', raw_length)[0] 13 | message = sys.stdin.buffer.read(message_length).decode("utf-8") 14 | return json.loads(message) 15 | 16 | while True: 17 | message = get_message() 18 | if type(message) == dict: 19 | subprocess.Popen([ "flow", "-u", message["url"], "-H", json.dumps(message["headers"]) ]) 20 | -------------------------------------------------------------------------------- /extension/meson.build: -------------------------------------------------------------------------------- 1 | fs = import('fs') 2 | 3 | configure_file( 4 | input: 'host.py.in', 5 | output: 'flow-connector', 6 | configuration: conf, 7 | install: true, 8 | install_dir: get_option('bindir') 9 | ) 10 | 11 | if fs.is_dir('/usr/lib/mozilla/native-messaging-hosts') 12 | install_data( 13 | 'com.github.essmehdi.flow.json', 14 | install_dir: '/usr/lib/mozilla/native-messaging-hosts' 15 | ) 16 | endif 17 | 18 | if fs.is_dir('/etc/opt/chrome/native-messaging-hosts') 19 | install_data( 20 | 'com.github.essmehdi.flow.json', 21 | install_dir: '/etc/opt/chrome/native-messaging-hosts' 22 | ) 23 | endif -------------------------------------------------------------------------------- /extension/src/catcher.js: -------------------------------------------------------------------------------- 1 | if (typeof browser === "undefined") { 2 | var browser = chrome; 3 | } 4 | 5 | const extensionsBlacklist = [ 6 | "html", "png", "jpg", "jpeg", "gif", "aspx", "php" 7 | ] 8 | 9 | // Context menu to force a download 10 | browser.menus.create({ 11 | id: "download-flow", 12 | title: "Download with Flow", 13 | contexts: ["link"] 14 | }); 15 | 16 | // Context menu item clicked 17 | browser.menus.onClicked.addListener(function(info, tab) { 18 | if (info.menuItemId === "download-flow") { 19 | browser.runtime.sendNativeMessage( 20 | "com.github.essmehdi.flow", { 21 | "url": info.linkUrl, 22 | "headers": [] 23 | } 24 | ); 25 | } 26 | }); 27 | 28 | function getFileDetailsDisposition(header) { 29 | const match = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(header); 30 | if (match && match[1]) { 31 | const filename = match[1].trim(); 32 | const dotIndex = filename.lastIndexOf('.'); 33 | console.log(filename, dotIndex); 34 | return { 35 | filename, 36 | extension: dotIndex > -1 ? filename.substring(dotIndex + 1) : "" 37 | } 38 | } 39 | } 40 | 41 | function getFileDetailsURL(url) { 42 | const parsed = new URL(url); 43 | const split = parsed.pathname.split('/'); 44 | const lastPath = split[split.length - 1]; 45 | const filename = lastPath || parsed.hostname; 46 | const dotIndex = lastPath.lastIndexOf('.'); 47 | return { 48 | filename, 49 | extension: dotIndex > -1 ? filename.substring(dotIndex + 1) : "" 50 | }; 51 | 52 | } 53 | 54 | function shouldCatch(details) { 55 | const headers = details.responseHeaders; 56 | const dispositionHeader = headers.find(header => header.name.toLowerCase() === 'content-disposition' && !header.value.startsWith("inline")); 57 | 58 | // Get file details from response 59 | const filedetails = dispositionHeader ? getFileDetailsDisposition(dispositionHeader.value) : getFileDetailsURL(details.url); 60 | 61 | // Download if there is 'Content-Disposition' or (file has an extension and not an browser openable file) 62 | return dispositionHeader || 63 | (filedetails.extension 64 | && !extensionsBlacklist.includes(filedetails.extension) 65 | && !headers.find(header => 66 | header.name.toLowerCase() === 'content-type' && header.value.includes('text/html') 67 | ) 68 | ) 69 | } 70 | 71 | browser.webRequest.onHeadersReceived.addListener(function (details) { 72 | if (details.type !== 'main_frame' && details.type !== 'sub_frame') return; 73 | if (shouldCatch(details)) { 74 | browser.storage.local.get(details.requestId, function(item) { 75 | browser.runtime.sendNativeMessage( 76 | "com.github.essmehdi.flow", { 77 | "url": details.url, 78 | "headers": item[details.requestId] 79 | } 80 | ); 81 | }); 82 | browser.storage.local.remove(details.requestId); 83 | return { cancel: true }; 84 | } 85 | }, { urls: [''] }, ['responseHeaders', 'blocking']); 86 | 87 | browser.webRequest.onSendHeaders.addListener(function (details) { 88 | browser.storage.local.set( 89 | { [details.requestId]: details.requestHeaders } 90 | ); 91 | }, { urls: [""] }, ['requestHeaders']); 92 | 93 | browser.webRequest.onCompleted.addListener(function (details) { 94 | browser.storage.local.remove(details.requestId); 95 | }, { urls: [""] }, ['responseHeaders']); 96 | 97 | browser.webRequest.onErrorOccurred.addListener(function (details) { 98 | browser.storage.local.remove(details.requestId); 99 | }, { urls: [""] }); 100 | -------------------------------------------------------------------------------- /extension/src/links.js: -------------------------------------------------------------------------------- 1 | document.querySelectorAll("a").forEach(link => link.addEventListener("click", event => { 2 | if (link.hasAttribute("download")) { 3 | event.preventDefault(); 4 | const url = link.getAttribute("href"); 5 | var final = url; 6 | try { 7 | new URL(final); 8 | } catch { 9 | final = window.location.origin + url; 10 | try { 11 | new URL(final); 12 | } catch { 13 | return; 14 | } 15 | } 16 | browser.runtime.sendMessage({ url: final }); 17 | } 18 | })); 19 | -------------------------------------------------------------------------------- /extension/src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Flow Intercepter", 3 | "description": "Download intercepter for Flow", 4 | "version": "1.0", 5 | "manifest_version": 2, 6 | "browser_specific_settings": { 7 | "gecko": { 8 | "id": "flowcatcher.essmehdi@github.com" 9 | } 10 | }, 11 | "background": { 12 | "scripts": ["catcher.js"] 13 | }, 14 | "permissions": [ 15 | "menus", "downloads", "nativeMessaging", "webRequest", "webRequestBlocking", "storage", "cookies", "" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /flow/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essmehdi/flow/2b70a567f74d7d00fce09f9c32e7595e1f01227e/flow/__init__.py -------------------------------------------------------------------------------- /flow/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essmehdi/flow/2b70a567f74d7d00fce09f9c32e7595e1f01227e/flow/core/__init__.py -------------------------------------------------------------------------------- /flow/core/controller.py: -------------------------------------------------------------------------------- 1 | from flow.ui.browser_wait import BrowserWait 2 | from flow.utils.notifier import Notifier 3 | from flow.core.download import Download 4 | from flow.core.status_manager import StatusManager 5 | from gi.repository import Gio, Gtk, GObject, Gdk, GLib 6 | from gettext import gettext as _ 7 | import logging 8 | 9 | 10 | class DownloadsController(GObject.GObject): 11 | __gtype_name__ = "DownloadsController" 12 | __instance = None 13 | 14 | selection_mode = GObject.Property(type=bool, default=False, flags=GObject.ParamFlags.READWRITE) 15 | 16 | @staticmethod 17 | def get_instance(): 18 | if DownloadsController.__instance is None: 19 | DownloadsController() 20 | return DownloadsController.__instance 21 | 22 | def __init__(self): 23 | if DownloadsController.__instance is not None: 24 | raise Exception("Cannot make another instance") 25 | else: 26 | DownloadsController.__instance = self 27 | GObject.GObject.__init__(self) 28 | self.finished_downloads = Gio.ListStore.new(Download) 29 | self.running_downloads = Gio.ListStore.new(Download) 30 | 31 | finished_downloads = StatusManager.get_downloads(True) 32 | for item in finished_downloads: 33 | self.finished_downloads.insert(0, Download(id=item['id'], status=item)) 34 | 35 | running_downloads = StatusManager.get_downloads(False) 36 | for item in running_downloads: 37 | self.running_downloads.insert(0, Download(id=item['id'], status=item)) 38 | 39 | self.finished_list_box = None 40 | self.running_list_box = None 41 | self.clamp = None 42 | self.content = None 43 | self.delete_action = None 44 | self.selected_downloads = set() 45 | self.empty_stack = None 46 | self.waiting_for_link = None 47 | 48 | def load_ui(self, window): 49 | # Register UI components 50 | self.finished_list_box = window.finished_downloads_list 51 | self.running_list_box = window.running_downloads_list 52 | self.clamp = window.clamp 53 | self.content = window.content 54 | self.finished_list_box.bind_model(self.finished_downloads, self._binder) 55 | self.finished_list_box.set_header_func(self._finished_header_function) 56 | self.running_list_box.bind_model(self.running_downloads, self._binder) 57 | self.delete_action = window.delete_selected 58 | self.empty_stack = window.empty_stack 59 | self._update_ui() 60 | 61 | def disable_edit_mode(self, id): 62 | download = self._get_download(id) 63 | download.edit_mode = False 64 | 65 | def enable_edit_mode(self, id): 66 | download = self._get_download(id) 67 | from ..ui.download_edit import DownloadEdit 68 | edit_window = DownloadEdit(False, download.id, download.url, download.filename, download.output_directory, transient_for=download.row.get_root(), application=download.row.get_root().get_application()) 69 | edit_window.show() 70 | download.edit_mode = True 71 | 72 | def confirm_download(self, download): 73 | download.row.get_root().present() 74 | from ..ui.download_edit import DownloadEdit 75 | edit_window = DownloadEdit(True, download.id, download.url, download.filename, download.output_directory, transient_for=download.row.get_root(), application=download.row.get_root().get_application()) 76 | edit_window.show() 77 | download.edit_mode = True 78 | 79 | def edit(self, id, new_filename, new_directory): 80 | logging.debug(f"Editing download - {new_filename} - {new_directory}") 81 | download = self._get_download(id) 82 | if download.status != 'done': 83 | if new_filename is not None: 84 | download.filename = new_filename 85 | if new_directory is not None: 86 | download.custom_directory = True 87 | download.output_directory = new_directory 88 | download.category = "" 89 | if download.edit_mode: 90 | download.edit_mode = False 91 | 92 | def download_finished(self, id): 93 | StatusManager.download_finished(id) 94 | index = self._find_position_from_running(id) 95 | download = self.running_downloads.get_item(index) 96 | self.running_downloads.remove(index) 97 | self.finished_downloads.insert(0, download) 98 | self._update_ui() 99 | # Notify user 100 | Notifier.notify(id, _("Download finished"), download.filename, "folder-download-symbolic") 101 | 102 | def add_from_url(self, url, headers=None, raw_headers=None): 103 | if self.waiting_for_link is None: 104 | self._create_download(url, headers, raw_headers) 105 | else: 106 | self._confirm_update(url, headers, raw_headers) 107 | #self._update_link(url, headers, self.waiting_for_link[0], raw_headers is not None) 108 | 109 | def _create_download(self, url, headers, raw_headers): 110 | self.running_downloads.insert(0, Download(url=url, headers=headers, raw_headers=raw_headers)) 111 | self._update_ui() 112 | 113 | def get_file(self, id): 114 | return self._get_download(id).get_file() 115 | 116 | def open(self, id, folder=False): 117 | self._get_download(id).open_folder() if folder else self._get_download(id).open_file() 118 | 119 | def pause(self, id): 120 | self._get_download(id).pause() 121 | 122 | def restart(self, id): 123 | download = self._get_download(id) 124 | if download.resumable: 125 | download.resume() 126 | else: 127 | url = download.url 128 | self._delete_with_id(id) 129 | self.add_from_url(url, raw_headers=download.custom_headers if download.custom_headers else None) 130 | 131 | def copy_url(self, id): 132 | # Set download URL as clipboard content 133 | clipboard = Gdk.Display.get_default().get_clipboard() 134 | clipboard.set_content(Gdk.ContentProvider.new_for_value(self._get_download(id).url)) 135 | 136 | def resume_with_link(self, id): 137 | download = self._get_download(id) 138 | popup = BrowserWait(transient_for=download.row.get_root(), application=download.row.get_root().get_application()) 139 | popup.show() 140 | self.waiting_for_link = (download, popup) 141 | 142 | def cancel_wait_for_link(self): 143 | self.waiting_for_link[1].destroy() 144 | self.waiting_for_link = None 145 | 146 | def _confirm_update(self, *download_details): 147 | self.waiting_for_link[1].show_confirm_dialog( 148 | download_details[0], 149 | lambda *__: self._update_link(*download_details), # Confirm 150 | lambda *__: self._separate_download(download_details) # Cancel 151 | ) 152 | 153 | def _separate_download(self, download_details): 154 | self.waiting_for_link[1].destroy() 155 | self.waiting_for_link = None 156 | self._create_download(*download_details) 157 | 158 | def _update_link(self, url, headers, raw_headers): 159 | self.waiting_for_link[1].destroy() 160 | download = self.waiting_for_link[0] 161 | self.waiting_for_link = None 162 | download.resumable = True 163 | download.url = url 164 | download.setup_request_headers(headers if headers is not None else raw_headers, raw_headers is not None) 165 | download.resume() 166 | 167 | def delete(self, id, delete_file=False): 168 | self._delete_with_id(id, delete_file) 169 | 170 | def delete_selected_rows(self, _, __, window): 171 | self._delete_dialog(window) 172 | 173 | def enable_selection(self): 174 | self.selection_mode = True 175 | 176 | def disable_selection(self): 177 | self.delete_action.set_enabled(False) 178 | self.selected_downloads = set() 179 | self.selection_mode = False 180 | 181 | def select(self, id): 182 | self.delete_action.set_enabled(True) 183 | self.selected_downloads.add(id) 184 | 185 | def unselect(self, id): 186 | self.selected_downloads.remove(id) 187 | if len(self.selected_downloads) == 0: 188 | self.disable_selection() 189 | 190 | def _delete_with_index(self, index, running=False, delete_file=False): 191 | d_list = self.running_downloads if running else self.finished_downloads 192 | download = d_list.get_item(index) 193 | if download.delete(delete_file): 194 | d_list.remove(index) 195 | self._update_ui() 196 | # Check if it exists in selected downloads & remove it 197 | if self.selection_mode and download.id in self.selected_downloads: 198 | self.unselect(download.id) 199 | 200 | def _delete_with_id(self, id, delete_file=False): 201 | index = self._find_position_from_finished(id) 202 | if index != -1: 203 | return self._delete_with_index(index, False, delete_file) 204 | index = self._find_position_from_running(id) 205 | if index != -1: 206 | return self._delete_with_index(index, True, delete_file) 207 | else: 208 | logging.error(f"Controller: Could not find download with id: {id}") 209 | 210 | def _delete_dialog(self, window): 211 | delete_files_check = Gtk.CheckButton() 212 | delete_files_check.set_label(_("Delete with files")) 213 | confirm_dialog = Gtk.MessageDialog( 214 | transient_for = window, 215 | message_type = Gtk.MessageType.WARNING, 216 | buttons = Gtk.ButtonsType.YES_NO, 217 | text = _("Are you sure ?"), 218 | secondary_text = _("This will delete the selected download(s) from history.") 219 | ) 220 | confirm_dialog.get_message_area().append(delete_files_check) 221 | confirm_dialog.connect("response", self._delete_dialog_response) 222 | confirm_dialog.set_modal(True) 223 | confirm_dialog.show() 224 | 225 | def _delete_dialog_response(self, dialog, response): 226 | delete_files_response = dialog.get_message_area().get_last_child().get_active() 227 | dialog.destroy() 228 | if response == Gtk.ResponseType.YES: 229 | self._confirm_delete_selected(delete_files_response) 230 | 231 | def _confirm_delete_selected(self, delete_files=False): 232 | selected_list = [ self._get_download(id) for id in self.selected_downloads ] 233 | for row in selected_list: 234 | self._delete_with_id(row.id, delete_files) 235 | self.disable_selection() 236 | 237 | def _update_row_date_tag(self, row): 238 | if row.date_finished != 0: 239 | today: GLib.DateTime = GLib.DateTime.new_now_local() 240 | finished: GLib.DateTime = GLib.DateTime.new_from_unix_local(row.date_finished) 241 | delta = today.difference(finished) // 10**6 # From microseconds to seconds 242 | hours = delta / 3600 243 | if 0 <= hours < 24: 244 | # Day scale 245 | if finished.get_hour() > today.get_hour(): 246 | row.date_tag = row.DATE_TAG_YESTERDAY 247 | else: 248 | row.date_tag = row.DATE_TAG_TODAY 249 | elif hours < 48 and finished.get_hour() < today.get_hour(): 250 | row.date_tag = row.DATE_TAG_YESTERDAY 251 | else: 252 | # Weeks scale 253 | if hours < 168: 254 | if finished.get_day_of_week() >= today.get_day_of_week(): 255 | row.date_tag = row.DATE_TAG_LAST_WEEK 256 | else: 257 | row.date_tag = finished.format('%A') 258 | elif hours < 336 and finished.get_day_of_week() < today.get_day_of_week(): 259 | row.date_tag = row.DATE_TAG_LAST_WEEK 260 | else: 261 | # Month scale 262 | days = hours // 24 263 | if days < 62: 264 | month_delta = today.get_month() - finished.get_month() 265 | if month_delta == 1 or month_delta == -11: 266 | row.date_tag = row.DATE_TAG_LAST_MONTH 267 | elif month_delta == 0: 268 | row.date_tag = row.DATE_TAG_THIS_MONTH 269 | else: 270 | # Year scale 271 | year_delta = today.get_year() - finished.get_year() 272 | if year_delta == 1: 273 | row.date_tag = row.DATE_TAG_LAST_YEAR 274 | elif year_delta == 0: 275 | row.date_tag = row.DATE_TAG_THIS_YEAR 276 | else: 277 | row.date_tag = row.DATE_TAG_MORE_YEAR 278 | 279 | def _finished_header_function(self, row, before, *_): 280 | self._update_row_date_tag(row) 281 | if before is None or row.date_tag != before.date_tag: 282 | label = Gtk.Label() 283 | label.set_label(row.date_tag) 284 | label.add_css_class("dim-label") 285 | label.set_halign(Gtk.Align.START) 286 | label.set_valign(Gtk.Align.CENTER) 287 | label.set_margin_start(10) 288 | label.set_margin_end(10) 289 | label.set_margin_top(10) 290 | label.set_margin_bottom(10) 291 | row.set_header(label) 292 | elif row.get_header() is not None: 293 | row.set_header(None) 294 | 295 | def _get_download(self, id): 296 | # Search in finished list 297 | index = self._find_position_from_finished(id) 298 | if index != -1: 299 | return self.finished_downloads.get_item(index) 300 | # Search in running list 301 | index = self._find_position_from_running(id) 302 | if index != -1: 303 | return self.running_downloads.get_item(index) 304 | else: 305 | logging.error(f"Could not find a download with the specified ID: {id}") 306 | 307 | def _find_position_from_finished(self, id): 308 | index = 0 309 | for download in self.finished_downloads: 310 | if download.id == id: 311 | return index 312 | index += 1 313 | return -1 314 | 315 | def _find_position_from_running(self, id): 316 | index = 0 317 | for download in self.running_downloads: 318 | if download.id == id: 319 | return index 320 | index += 1 321 | return -1 322 | 323 | def _foreach(self, box, row, *__): 324 | logging.debug(row.filename) 325 | 326 | def _binder(self, item): 327 | from ..ui.download_item import DownloadItem 328 | row = DownloadItem() 329 | item.bind_property("id", row, "id", GObject.BindingFlags.SYNC_CREATE) 330 | item.bind_property("status", row, "status", GObject.BindingFlags.SYNC_CREATE) 331 | item.bind_property("filename", row, "filename", GObject.BindingFlags.SYNC_CREATE) 332 | item.bind_property("resumable", row, "resumable", GObject.BindingFlags.SYNC_CREATE) 333 | item.bind_property("progress", row, "progress", GObject.BindingFlags.SYNC_CREATE) 334 | item.bind_property("size", row, "size", GObject.BindingFlags.SYNC_CREATE) 335 | item.bind_property("url", row, "url", GObject.BindingFlags.SYNC_CREATE) 336 | item.bind_property("output_directory", row, "output_directory", GObject.BindingFlags.SYNC_CREATE) 337 | item.bind_property("category", row, "category", GObject.BindingFlags.SYNC_CREATE) 338 | item.bind_property("date_started", row, "date_started", GObject.BindingFlags.SYNC_CREATE) 339 | item.bind_property("date_initiated", row, "date_initiated", GObject.BindingFlags.SYNC_CREATE) 340 | item.bind_property("date_finished", row, "date_finished", GObject.BindingFlags.SYNC_CREATE) 341 | self.bind_property("selection_mode", row, "selection_mode", GObject.BindingFlags.SYNC_CREATE) 342 | item.row = row 343 | return row 344 | 345 | def _update_ui(self): 346 | if self.finished_list_box is not None: 347 | finished_count = self.finished_downloads.get_n_items() > 0 348 | running_count = self.running_downloads.get_n_items() > 0 349 | # Frame ListBox 350 | self.finished_list_box.get_parent().get_parent().set_visible(finished_count) 351 | self.running_list_box.get_parent().get_parent().set_visible(running_count) 352 | if not finished_count and not running_count: 353 | self.empty_stack.set_visible_child_name('empty_page') 354 | else: 355 | self.empty_stack.set_visible_child_name('not_empty_page') -------------------------------------------------------------------------------- /flow/core/download.py: -------------------------------------------------------------------------------- 1 | import os 2 | import threading 3 | import uuid 4 | from gi.repository import GObject, GLib, Gio 5 | import pycurl 6 | 7 | from flow.utils.notifier import Notifier 8 | 9 | from flow.utils.misc import create_download_temp, file_extension_match 10 | 11 | from flow.core.status_manager import StatusManager 12 | import logging 13 | import shutil 14 | import mimetypes 15 | import cgi 16 | import time 17 | from urllib.parse import unquote, urlparse 18 | from flow.core.settings import Settings 19 | from flow.utils.toaster import Toaster 20 | from gettext import gettext as _ 21 | import validators 22 | 23 | 24 | class Download(GObject.Object): 25 | __gtype_name__ = "Download" 26 | 27 | # Properties that should not be saved to status file 28 | PROPERTIES_BLACKLIST = ["progress", "open_on_finish"] 29 | 30 | # Status strings 31 | STATUS_STARTED = 'downloading' 32 | STATUS_REQUEST_ERROR = 'request_error' 33 | STATUS_MOVE_ERROR = 'move_error' 34 | STATUS_RESUME_ERROR = 'resume_error' 35 | STATUS_DONE = 'done' 36 | STATUS_TIMEOUT_ERROR = 'read_timeout_error' 37 | STATUS_CONNECTION_ERROR = 'connection_error' 38 | STATUS_INIT = 'init' 39 | STATUS_PAUSED = 'paused' 40 | STATUS_MOVING = 'moving' 41 | 42 | # Download path fallback 43 | DOWNLOAD_FALLBACK_PATH = "~/Downloads" 44 | 45 | # Download properties 46 | id = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 47 | status = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 48 | url = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 49 | tmp = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 50 | output_directory = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 51 | custom_directory = GObject.Property(type=bool, default=False, flags=GObject.ParamFlags.READWRITE) 52 | date_initiated = GObject.Property(type=GObject.GType.from_name('gulong'), default=0, flags=GObject.ParamFlags.READWRITE) 53 | date_started = GObject.Property(type=GObject.GType.from_name('gulong'), default=0, flags=GObject.ParamFlags.READWRITE) 54 | date_finished = GObject.Property(type=GObject.GType.from_name('gulong'), default=0, flags=GObject.ParamFlags.READWRITE) 55 | filename = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 56 | category = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 57 | size = GObject.Property(type=GObject.GType.from_name('glong'), default=-1, flags=GObject.ParamFlags.READWRITE) 58 | resumable = GObject.Property(type=bool, default=False, flags=GObject.ParamFlags.READWRITE) 59 | progress = GObject.Property(type=GObject.GType.from_name('gulong'), default=0, flags=GObject.ParamFlags.READWRITE) 60 | open_on_finish = GObject.Property(type=bool, default=False, flags=GObject.ParamFlags.READWRITE) 61 | custom_headers = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 62 | info_parsed = GObject.Property(type=bool, default=False, flags=GObject.ParamFlags.READWRITE) 63 | 64 | def __init__(self, id: str = None, status: dict = None, url: str = None, headers: dict = None, raw_headers: str = None): 65 | GObject.Object.__init__(self) 66 | # Update fallback path based on user settings 67 | fallback_directory = Settings.get().fallback_directory 68 | if fallback_directory: 69 | Download.DOWNLOAD_FALLBACK_PATH = fallback_directory 70 | # UI item for easy access 71 | self.row = None 72 | # Edit mode flag 73 | self.edit_mode = False 74 | # cURL worker 75 | self.worker = None 76 | self.worker_thread = None 77 | # Progress offset (when cURL resumes download, count starts from 0 not from offset) 78 | self.offset = 0 79 | # Response headers 80 | self.response_headers = {} 81 | # Write mode 82 | self.mode = "wb" 83 | # Cancel flag & confirmation 84 | self.cancel = False 85 | self.cancelled = False 86 | # Custom user agent 87 | self.ua = None 88 | if not status: 89 | # Means that this is a new download 90 | new_temp_file = create_download_temp(str(uuid.uuid4())) 91 | status = StatusManager.register_download(url=url, tmp=new_temp_file.get_path()) 92 | # Notify user 93 | Notifier.notify(str(status['id']), _("Download initiated"), url, "folder-download-symbolic") 94 | else: 95 | self.id = id 96 | self.populate_properties(status) 97 | self.connect("notify", self.handle_property_change) 98 | if headers is not None: 99 | logging.debug("New download with custom headers") 100 | self.setup_request_headers(headers) 101 | elif raw_headers is not None: 102 | logging.debug("New download with custom headers (raw)") 103 | self.setup_request_headers(raw_headers, True) 104 | self.setup() 105 | 106 | def setup_request_headers(self, headers, raw=False): 107 | logging.debug('Registering request headers') 108 | if raw: 109 | self.custom_headers = headers 110 | else: 111 | new_custom_headers = '' 112 | for line in headers: 113 | if line.get('value'): 114 | if line.get('name') == 'User-Agent': 115 | self.ua = line.get('value') 116 | new_custom_headers += f",{line['name']}: {line['value']}" 117 | self.custom_headers = new_custom_headers 118 | 119 | def populate_properties(self, status: dict): 120 | # Populate properties with provided status object 121 | for property in status.keys(): 122 | value = status[property] 123 | if value is not None: 124 | setattr(self, property, value) 125 | 126 | def setup(self): 127 | if self.status != Download.STATUS_DONE: 128 | self.worker = pycurl.Curl() 129 | self.worker.setopt(pycurl.FAILONERROR, True) 130 | self.worker.setopt(pycurl.URL, self.url) 131 | self.worker.setopt(pycurl.FOLLOWLOCATION, True) 132 | if len(self.custom_headers): 133 | self.worker.setopt(pycurl.HTTPHEADER, self.custom_headers.split(',')) 134 | if self.status == Download.STATUS_MOVING: 135 | if os.path.exists(self.tmp): 136 | self._move_file() 137 | else: 138 | self._finalize() 139 | if self.status == Download.STATUS_STARTED: 140 | # Means probably that program closed unexpectedly. 141 | # Therefore, change status value to 'paused' 142 | self.status = Download.STATUS_PAUSED 143 | if self.status == Download.STATUS_PAUSED or ('error' in self.status and self.resumable): 144 | if os.path.exists(self.tmp): 145 | self.offset = os.path.getsize(self.tmp) 146 | self.worker.setopt(pycurl.RESUME_FROM, self.offset) 147 | self.progress = self.offset 148 | self.mode = "ab" 149 | else: 150 | self.resumable = False 151 | if self.status == "": 152 | self.start() 153 | 154 | def pause(self, *_): 155 | self.worker.pause(pycurl.PAUSE_ALL) 156 | self.status = Download.STATUS_PAUSED 157 | 158 | def resume(self, *_): 159 | if self.worker_thread is not None and self.status == 'paused': 160 | self.worker.pause(pycurl.PAUSE_CONT) 161 | self.status = Download.STATUS_STARTED 162 | else: 163 | self.setup() 164 | self.start() 165 | 166 | def start(self): 167 | # Starts download 168 | self.get_download_preferences() 169 | self.worker_thread = threading.Thread(target=self._start_download, daemon=True) 170 | self.worker_thread.start() 171 | 172 | def header_function(self, header_line): 173 | header_line = header_line.decode('iso-8859-1') 174 | if ':' not in header_line: 175 | if header_line == "\r\n" and len(self.response_headers.keys()): 176 | self._decode_headers() 177 | return 178 | name, value = header_line.split(':', 1) 179 | name = name.strip().lower() 180 | value = value.strip() 181 | self.response_headers[name] = value 182 | 183 | def _decode_headers(self): 184 | if not self.info_parsed: 185 | # Detect filename 186 | reported = False 187 | logging.debug(self.response_headers) 188 | if 'content-disposition' in self.response_headers: 189 | _, params = cgi.parse_header(self.response_headers['content-disposition']) 190 | if 'filename' in params: 191 | self.filename = unquote(params.get('filename').encode('iso-8859-1').decode('utf8')) 192 | reported = True 193 | if not reported: 194 | parsed_url = urlparse(self.url) 195 | filename = unquote(parsed_url.path).split("/")[-1] 196 | if len(filename) >= 200: 197 | filename = filename[-1:-100:-1].strip() 198 | elif filename == "": 199 | filename = parsed_url.netloc 200 | if "content-type" in self.response_headers and self.response_headers['content-type'] != 'application/octet-stream': 201 | ext = mimetypes.guess_extension(self.response_headers['content-type']) 202 | if ext and not mimetypes.guess_type(filename)[0] == self.response_headers['content-type'] and not filename.endswith(ext): 203 | filename += ext 204 | self.filename = filename 205 | 206 | # Detect category 207 | if self.output_directory == "" and not self.custom_directory: 208 | _, extension = os.path.splitext(self.filename) 209 | categories = Settings.get().categories 210 | if extension: 211 | for category, settings in categories.items(): 212 | if file_extension_match(self.filename, settings.get('extensions')): 213 | directory = settings.get("path") 214 | if os.path.exists(directory): 215 | self.category = category 216 | self.output_directory = directory 217 | else: 218 | self.output_directory = os.path.expanduser(Download.DOWNLOAD_FALLBACK_PATH) 219 | break 220 | logging.debug(f"Detected output directory: {self.output_directory}") 221 | if self.output_directory == "": 222 | self.output_directory = os.path.expanduser(Download.DOWNLOAD_FALLBACK_PATH) 223 | 224 | # Detect if download resumable 225 | if 'content-length' in self.response_headers: 226 | self.size = int(self.response_headers.get('content-length')) 227 | self.resumable = True 228 | # if 'accept-ranges' in self.response_headers: 229 | # self.resumable = True 230 | 231 | # Flag info as parsed (In case of resume) 232 | self.info_parsed = True 233 | # from .controller import DownloadsController 234 | # GLib.idle_add(lambda: DownloadsController.get_instance().confirm_download(self)) 235 | 236 | def _start_download(self): 237 | # Start data transfer 238 | logging.info("Starting download") 239 | self.worker.setopt(pycurl.NOPROGRESS, False) 240 | self.worker.setopt(pycurl.XFERINFOFUNCTION, self.report_progress) 241 | if not self.info_parsed: 242 | self.worker.setopt(pycurl.HEADERFUNCTION, self.header_function) 243 | self.status = Download.STATUS_STARTED 244 | self.date_started = GLib.DateTime.new_now_local().to_unix() 245 | try: 246 | if self.offset != self.size: 247 | with open(self.tmp, self.mode) as file: 248 | self.worker.setopt(pycurl.WRITEDATA, file) 249 | self.worker.perform() 250 | except pycurl.error as error: 251 | self._handle_error(error.args) 252 | else: 253 | self.date_finished = GLib.DateTime.new_now_local().to_unix() 254 | if self.size < 0: 255 | self.size = os.path.getsize(self.tmp) 256 | while self.edit_mode and not self.cancel: 257 | logging.debug("Waiting for edit mode...") 258 | time.sleep(1) 259 | if self.cancel: 260 | self.cancelled = True 261 | self.worker.close() 262 | return 263 | self.worker.close() 264 | self.status = Download.STATUS_MOVING 265 | self._move_file() 266 | self._finalize() 267 | 268 | def _move_file(self): 269 | output_path = self.get_output_file_path() 270 | if os.path.exists(output_path): 271 | count = 1 272 | while True: 273 | name, ext = os.path.splitext(self.filename) 274 | name += f" ({count})" 275 | dup_filename = name + ext 276 | if not os.path.exists(os.path.join(self.output_directory, dup_filename)): 277 | self.filename = dup_filename 278 | output_path = self.get_output_file_path() 279 | break 280 | count += 1 281 | logging.debug(f"Moving file to: {output_path}") 282 | shutil.move(self.tmp, output_path) 283 | if self.open_on_finish: 284 | self.open_file() 285 | 286 | def _finalize(self): 287 | self.status = Download.STATUS_DONE 288 | from .controller import DownloadsController # To avoid circular import 289 | GLib.idle_add(lambda: DownloadsController.get_instance().download_finished(self.id)) 290 | logging.info("Download finished") 291 | 292 | def _handle_error(self, args): 293 | logging.exception(f"Error while performing cURL: {args[1]}") 294 | match args[0]: 295 | case pycurl.E_ABORTED_BY_CALLBACK: 296 | logging.info("Download cancelled") 297 | self.cancelled = True 298 | self.cancel = False 299 | case pycurl.E_HTTP_RETURNED_ERROR: 300 | self.status = Download.STATUS_REQUEST_ERROR 301 | case pycurl.E_OPERATION_TIMEDOUT: 302 | self.status = Download.STATUS_TIMEOUT_ERROR 303 | case pycurl.E_RANGE_ERROR: 304 | self.status = Download.STATUS_RESUME_ERROR 305 | self.resumable = False 306 | case _: 307 | self.status = Download.STATUS_CONNECTION_ERROR 308 | # Notify user 309 | if args[0] != pycurl.E_ABORTED_BY_CALLBACK: 310 | Notifier.notify(self.id, _("Downlod error"), _("An error occured while downloading"), "folder-download-symbolic") 311 | 312 | def get_output_file_path(self): 313 | if self.output_directory: 314 | return os.path.join(self.output_directory, self.filename) 315 | else: 316 | logging.error("Could not get file path") 317 | 318 | def get_file(self): 319 | file_path = self.get_output_file_path() 320 | if file_path is not None and os.path.exists(file_path): 321 | return Gio.File.new_for_path(file_path) 322 | 323 | def report_progress(self, __, downloaded, *_): 324 | if self.cancel: 325 | return -1 # Non zero value to abort transfer 326 | self.progress = self.offset + downloaded 327 | 328 | def get_download_preferences(self): 329 | # Setup connection timeout 330 | timeout = Settings.get().timeout 331 | logging.debug(f"Preference timeout: {timeout}s") 332 | self.worker.setopt(pycurl.CONNECTTIMEOUT, timeout) 333 | self.worker.setopt(pycurl.LOW_SPEED_LIMIT, 1) 334 | self.worker.setopt(pycurl.LOW_SPEED_TIME, timeout) 335 | # Setup request user agent 336 | user_agent = Settings.get().user_agent 337 | if self.ua is not None: 338 | logging.debug(f"Header user agent: {self.ua}") 339 | self.worker.setopt(pycurl.USERAGENT, self.ua) 340 | elif user_agent: 341 | logging.debug(f"Preference user agent: {user_agent}") 342 | self.worker.setopt(pycurl.USERAGENT, user_agent) 343 | # Setup proxy settings 344 | use_proxy = Settings.get().use_proxy 345 | proxy_address = Settings.get().proxy_address 346 | logging.debug(f"Preference proxy enabled: {use_proxy}") 347 | if use_proxy and proxy_address and validators.url(proxy_address): 348 | logging.debug('Proxy: Using custom settings') 349 | self.worker.setopt(pycurl.PROXY, proxy_address) 350 | self.worker.setopt(pycurl.PROXYPORT, Settings.get().proxy_port) 351 | else: 352 | # Use GNOME proxy settings 353 | logging.debug('Proxy: Using GNOME settings') 354 | proxy_resolver = Gio.ProxyResolver.get_default() 355 | proxy = proxy_resolver.lookup(self.url) 356 | logging.debug(f'GNOME Proxy Resolver: {proxy[0]}') 357 | if not proxy[0].startswith('direct://'): 358 | self.worker.setopt(pycurl.PROXY, proxy[0]) 359 | 360 | def open_file(self): 361 | if self.status == 'done': 362 | file_path = self.get_output_file_path() 363 | if os.path.exists(file_path): 364 | logging.debug(f"Launching file: {file_path}") 365 | Gio.AppInfo.launch_default_for_uri("file://" + file_path) 366 | else: 367 | Toaster.show(_("The file has been moved or deleted")) 368 | else: 369 | self.open_on_finish = True 370 | Toaster.show(_("The file will be automatically opened once download is finished")) 371 | 372 | def open_folder(self): 373 | if self.status == 'done': 374 | Gio.AppInfo.launch_default_for_uri("file://" + self.output_directory) 375 | 376 | def handle_property_change(self, _, prop): 377 | # Apparently, property names are incorrectly set. Underscores are considered 378 | # dashes. Why ? :/ 379 | property = prop.name.replace("-", "_") 380 | value = getattr(self, property) 381 | if property not in Download.PROPERTIES_BLACKLIST: 382 | logging.debug(f"Property {property} changed to {value}") 383 | StatusManager.update_property(self.id, property, value) 384 | 385 | def delete(self, delete_file = False): 386 | if self.status == 'downloading': 387 | self.cancel = True 388 | while not self.cancelled: 389 | logging.debug("Waiting for cancellation...") 390 | time.sleep(0.5) 391 | try: 392 | StatusManager.remove_download(self.id, delete_file) 393 | except: 394 | logging.exception("Failed to delete download") 395 | return False 396 | else: 397 | return True -------------------------------------------------------------------------------- /flow/core/meson.build: -------------------------------------------------------------------------------- 1 | moduledir = pkgdatadir / 'flow' / 'core' 2 | 3 | sources = [ 4 | '__init__.py', 5 | 'controller.py', 6 | 'download.py', 7 | 'settings.py', 8 | 'status_manager.py' 9 | ] 10 | 11 | install_data(sources, install_dir: moduledir) -------------------------------------------------------------------------------- /flow/core/settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from gi.repository import Gio, GLib 3 | 4 | class Settings(Gio.Settings): 5 | instance: 'Settings' = None 6 | 7 | def __init__(self): 8 | Gio.Settings.__init__(self) 9 | 10 | @staticmethod 11 | def new(): 12 | g_settings = Gio.Settings.new('com.github.essmehdi.flow') 13 | g_settings.__class__ = Settings 14 | return g_settings 15 | 16 | @staticmethod 17 | def get(): 18 | if Settings.instance is None: 19 | Settings.instance = Settings.new() 20 | return Settings.instance 21 | 22 | @property 23 | def categories(self): 24 | return dict(self.get_value("categories")) 25 | 26 | @categories.setter 27 | def categories(self, new_categories): 28 | self.set_value("categories", GLib.Variant('a{sa{ss}}', new_categories)) 29 | 30 | def add_category(self, category, path, extensions): 31 | if category in self.categories: 32 | self.set_category_path(category, path) 33 | else: 34 | categories = self.categories 35 | categories[category] = { "path": path, "extensions": extensions } 36 | self.categories = categories 37 | 38 | def set_category_settings(self, category, path, extensions, old_category=None): 39 | categories = self.categories 40 | if old_category: 41 | categories[category] = { 42 | "path": path, 43 | "extensions": extensions 44 | } 45 | del categories[old_category] 46 | self.categories = categories 47 | elif category in categories: 48 | categories[category]["path"] = path 49 | categories[category]["extensions"] = extensions 50 | self.categories = categories 51 | 52 | def get_category_path(self, category): 53 | categories = self.categories 54 | if category in categories: 55 | return categories[category].get("path") 56 | 57 | def remove_category(self, category): 58 | categories = self.categories 59 | if category in categories: 60 | del categories[category] 61 | self.categories = categories 62 | 63 | @property 64 | def timeout(self): 65 | return self.get_uint('connection-timeout') 66 | 67 | @property 68 | def use_proxy(self): 69 | return self.get_boolean('connection-proxy') 70 | 71 | @property 72 | def proxy_address(self): 73 | return self.get_string('connection-proxy-address') 74 | 75 | @property 76 | def proxy_port(self): 77 | return self.get_uint('connection-proxy-port') 78 | 79 | @property 80 | def user_agent(self): 81 | return self.get_string('user-agent') 82 | 83 | @property 84 | def force_dark(self): 85 | return self.get_boolean('force-dark') 86 | 87 | @property 88 | def fallback_directory(self): 89 | return self.get_string('fallback-directory') -------------------------------------------------------------------------------- /flow/core/status_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import json 4 | import sqlite3 5 | from gi.repository import GLib 6 | 7 | class StatusManager: 8 | 9 | DB_PATH = os.path.join(GLib.get_home_dir(), '.flow/data.db') 10 | 11 | @staticmethod 12 | def create_tables(): 13 | con = sqlite3.connect(StatusManager.DB_PATH) 14 | cur = con.cursor() 15 | cur.execute('''CREATE TABLE downloads ( 16 | id INTEGER CONSTRAINT pk_id PRIMARY KEY, 17 | filename TEXT, 18 | url TEXT, 19 | date_initiated INTEGER, 20 | date_started INTEGER, 21 | date_finished INTEGER, 22 | tmp TEXT, 23 | custom_headers TEXT, 24 | status TEXT, 25 | category TEXT, 26 | output_directory TEXT, 27 | size INTEGER, 28 | resumable INTEGER, 29 | info_parsed INTEGER 30 | )''') 31 | con.commit() 32 | con.close() 33 | 34 | @staticmethod 35 | def get_connection(): 36 | logging.info(f"Connecting to SQLite database: {StatusManager.DB_PATH}") 37 | if not os.path.exists(StatusManager.DB_PATH): 38 | StatusManager.create_tables() 39 | con = sqlite3.connect(StatusManager.DB_PATH) 40 | con.row_factory = sqlite3.Row 41 | return con 42 | 43 | @staticmethod 44 | def get_downloads(finished, category=None): 45 | with StatusManager.get_connection() as con: 46 | cur = con.cursor() 47 | request = f"SELECT * FROM downloads WHERE status {'=' if finished else '<>'} 'done' { 'WHERE category=?' if category else '' } ORDER BY date_finished ASC" 48 | params = (category,) if category else tuple() 49 | cur.execute(request, params) 50 | results = cur.fetchall() 51 | cur.close() 52 | return results 53 | con.close() 54 | 55 | @staticmethod 56 | def update_property(id, property, value): 57 | with StatusManager.get_connection() as con: 58 | query = f"UPDATE downloads SET { property } = ? WHERE id = ?" 59 | cur = con.cursor() 60 | cur.execute(query, (value, id)) 61 | cur.close() 62 | con.close() 63 | 64 | def register_download(**kwargs): 65 | kwargs['date_initiated'] = GLib.DateTime.new_now_local().to_unix() 66 | with StatusManager.get_connection() as con: 67 | columns = ','.join(kwargs.keys()) 68 | cur = con.cursor() 69 | cur.execute(f"INSERT INTO downloads ({ columns }) VALUES (?{ ',?' * (len(kwargs.values())-1) })", tuple(kwargs.values())) 70 | kwargs['id'] = cur.lastrowid 71 | return kwargs 72 | con.close() 73 | 74 | def remove_download(id, delete_file=False): 75 | with StatusManager.get_connection() as con: 76 | cur = con.cursor() 77 | cur.execute('SELECT filename, output_directory, tmp, size, status FROM downloads WHERE id = ?', (id,)) 78 | result = cur.fetchone() 79 | cur.close() 80 | if result: 81 | path = os.path.join(result['output_directory'], result['filename']) 82 | if delete_file and result['status'] == 'done': 83 | path = os.path.join(result['output_directory'], result['filename']) 84 | # A naive precaution to be sure it's the downloaded file 85 | if os.path.exists(path) and os.path.getsize(path) == result['size']: 86 | logging.info("File found. Deleting...") 87 | os.remove(path) 88 | else: 89 | logging.info("File not found") 90 | if result['status'] != 'done': 91 | tmp = result['tmp'] 92 | if tmp and os.path.exists(tmp): 93 | os.remove(tmp) 94 | 95 | con.execute("DELETE FROM downloads WHERE id = ?", (id,)) 96 | else: 97 | logging.error(f"Attempted to delete a non-existant download (ID: {id})") 98 | con.close() 99 | 100 | @staticmethod 101 | def download_finished(id): 102 | with StatusManager.get_connection() as con: 103 | con.execute("UPDATE downloads SET status = 'done' WHERE id = ?", (id,)) 104 | con.close() 105 | 106 | 107 | if __name__ == '__main__': 108 | logging.info(StatusManagerDB.get_downloads(False)) -------------------------------------------------------------------------------- /flow/load.py.in: -------------------------------------------------------------------------------- 1 | #!@PYTHON@ 2 | 3 | import sys 4 | import os 5 | import locale 6 | import gettext 7 | 8 | VERSION = "@VERSION@" 9 | pkgdatadir = "@pkgdatadir@" 10 | localedir = '@localedir@' 11 | 12 | try: 13 | locale.bindtextdomain('flow', localedir) 14 | locale.textdomain('flow') 15 | except: 16 | print('Locale processing failed') 17 | try: 18 | gettext.bindtextdomain('flow', localedir) 19 | gettext.textdomain('flow') 20 | except: 21 | print('Translation processing failed') 22 | 23 | sys.path.insert(1, pkgdatadir) 24 | 25 | if __name__ == "__main__": 26 | from gi.repository import Gio 27 | resources = Gio.Resource.load(os.path.join(pkgdatadir, 'flow.gresource')) 28 | resources._register() 29 | 30 | from flow.utils.app_info import AppInfo 31 | AppInfo.app_id = "@appid@" 32 | AppInfo.name = "@appname@" 33 | AppInfo.profile = "@profile@" 34 | 35 | from flow import main 36 | main.main(VERSION) -------------------------------------------------------------------------------- /flow/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import gi 3 | import sys 4 | import logging 5 | 6 | gi.require_version("Gtk", "4.0") 7 | gi.require_version("Adw", "1") 8 | 9 | from flow.ui.shortcuts import Shortcuts 10 | from flow.utils.notifier import Notifier 11 | from flow.utils.toaster import Toaster 12 | from gi.repository import Gtk, Adw, Gio, GObject 13 | from flow.ui.preferences.window import PreferencesWindow 14 | from flow.core.controller import DownloadsController 15 | from flow.ui.url_prompt import URLPrompt 16 | from flow.ui.about import AboutDialog 17 | from flow.core.settings import Settings 18 | from flow.utils.app_info import AppInfo 19 | from gettext import gettext as _ 20 | import argparse 21 | 22 | 23 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/main.ui") 24 | class FlowWindow(Adw.ApplicationWindow): 25 | 26 | __gtype_name__ = "FlowWindow" 27 | 28 | clamp = Gtk.Template.Child() 29 | content = Gtk.Template.Child() 30 | finished_downloads_list = Gtk.Template.Child() 31 | running_downloads_list = Gtk.Template.Child() 32 | scrolled_window = Gtk.Template.Child() 33 | header_bar = Gtk.Template.Child() 34 | toast_overlay = Gtk.Template.Child() 35 | empty_stack = Gtk.Template.Child() 36 | selection_mode_toggle = Gtk.Template.Child() 37 | 38 | def __init__(self, **kwargs): 39 | super().__init__(**kwargs) 40 | self.about = None 41 | # Discard selection mode action 42 | self.discard_action = Gio.SimpleAction(name='discard-selection-mode') 43 | self.discard_action.connect('activate', self.discard_selection_mode) 44 | self.add_action(self.discard_action) 45 | # New download action 46 | self.new_action = Gio.SimpleAction(name='new') 47 | self.new_action.connect('activate', self.show_url_prompt) 48 | self.add_action(self.new_action) 49 | # List actions 50 | self.delete_selected = Gio.SimpleAction(name='delete-selected') 51 | self.delete_selected.connect('activate', DownloadsController.get_instance().delete_selected_rows, self) 52 | self.delete_selected.set_enabled(False) 53 | self.add_action(self.delete_selected) 54 | # Preferences action 55 | preferences_action = Gio.SimpleAction(name='preferences') 56 | preferences_action.connect('activate', self.preferences_action_handler) 57 | self.add_action(preferences_action) 58 | # Shortcuts 59 | shortcuts_action = Gio.SimpleAction(name='shortcuts') 60 | shortcuts_action.connect('activate', self.shortcuts_action_handler) 61 | self.add_action(shortcuts_action) 62 | # About action 63 | about_action = Gio.SimpleAction(name='about') 64 | about_action.connect('activate', self.about_action_handler) 65 | self.add_action(about_action) 66 | # Init the controller 67 | DownloadsController.get_instance().load_ui(self) 68 | # Register toast overlay 69 | Toaster.register_overlay(self.toast_overlay) 70 | # Devel header 71 | if AppInfo.profile == 'development': 72 | self.add_css_class('devel') 73 | # Save window state 74 | settings = Gio.Settings("com.github.essmehdi.flow") 75 | settings.bind('window-width', self, 'default-width', Gio.SettingsBindFlags.DEFAULT) 76 | settings.bind('window-height', self, 'default-height', Gio.SettingsBindFlags.DEFAULT) 77 | settings.bind('window-maximized', self, 'maximized', Gio.SettingsBindFlags.DEFAULT) 78 | # Selection mode toggle 79 | self.selection_mode_toggle.bind_property('active', DownloadsController.get_instance(), 'selection-mode', GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL) 80 | 81 | def discard_selection_mode(self, *_): 82 | self.selection_mode_toggle.set_active(False) 83 | 84 | def show_url_prompt(self, *_): 85 | # New download prompt 86 | URLPrompt(transient_for=self, application=self.get_application()).show() 87 | 88 | def preferences_action_handler(self, *_): 89 | PreferencesWindow(transient_for=self, application=self.get_application()).show() 90 | 91 | def about_action_handler(self, *_): 92 | app = self.get_application() 93 | AboutDialog(app.version, application=app, transient_for=self).show() 94 | 95 | def shortcuts_action_handler(self, *_): 96 | Shortcuts().show() 97 | 98 | 99 | class MainApplication(Adw.Application): 100 | 101 | version = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 102 | 103 | def __init__(self, version, **kwargs): 104 | super().__init__(application_id=AppInfo.app_id, **kwargs) 105 | Notifier.init(self) 106 | self.window = None 107 | self.version = version 108 | # Raise window action 109 | self.raise_action = Gio.SimpleAction(name='raise') 110 | self.raise_action.connect('activate', self.raise_main_window) 111 | self.raise_action.set_enabled(False) 112 | self.add_action(self.raise_action) 113 | # Setup shortcuts 114 | self.setup_shortcuts() 115 | 116 | def setup_shortcuts(self): 117 | self.set_accels_for_action("win.preferences", ["comma", None]) 118 | self.set_accels_for_action("win.shortcuts", ["question", None]) 119 | self.set_accels_for_action("win.delete-selected", ["Delete", None]) 120 | self.set_accels_for_action("win.new", ["n", None]) 121 | self.set_accels_for_action("win.discard-selection-mode", ["Escape", None]) 122 | self.set_accels_for_action("prompt.confirm", ["Return", None]) 123 | self.set_accels_for_action("prompt.cancel", ["Escape", None]) 124 | self.set_accels_for_action("dedit.save", ["Return", None]) 125 | self.set_accels_for_action("dedit.cancel", ["Escape", None]) 126 | self.set_accels_for_action("cedit.save", ["Return", None]) 127 | self.set_accels_for_action("cedit.cancel", ["Escape", None]) 128 | 129 | def raise_main_window(self, *__): 130 | logging.debug("Raising window") 131 | self.window.present() 132 | 133 | def do_activate(self): 134 | style_manager = Adw.StyleManager.get_default() 135 | style_manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK if Settings.get().force_dark else Adw.ColorScheme.PREFER_LIGHT) 136 | self.window = self.props.active_window 137 | if not self.window: 138 | self.window = FlowWindow(application=self) 139 | self.window.show() 140 | self.raise_action.set_enabled(True) 141 | 142 | def do_command_line(self, acl): 143 | parser = argparse.ArgumentParser( 144 | description="A download manager for GNOME" 145 | ) 146 | parser.add_argument( 147 | "-H", "--headers", 148 | metavar="headers", 149 | help=_("Download request headers"), 150 | required=False 151 | ) 152 | parser.add_argument( 153 | "-u", "--url", 154 | metavar="url", 155 | help=_("Download link"), 156 | required=False 157 | ) 158 | parser.add_argument( 159 | "-l", "--logs-level", 160 | metavar="level", 161 | dest="level", 162 | help=_("Logs verbose level"), 163 | required=False 164 | ) 165 | args = parser.parse_args(acl.get_arguments()[1:]) 166 | 167 | # Setup logs 168 | level = None 169 | format = "[%(levelname)s] %(message)s" 170 | if args.level: 171 | level = getattr(logging, args.level) 172 | if not isinstance(level, int): 173 | print("Unspecified or invalid logs level. Fallback to INFO.") 174 | logging.basicConfig(level=logging.INFO, format=format, datefmt="%Y-%m-%d %H:%M:%S ") 175 | else: 176 | logging.basicConfig(level=level, format=format) 177 | 178 | if args.url: 179 | logging.debug(args.headers) 180 | DownloadsController.get_instance().add_from_url(args.url, json.loads(args.headers) if args.headers is not None else {}) 181 | elif self.window and not self.window.get_visible(): 182 | logging.debug("Window hidden") 183 | self.window.show() 184 | self.do_activate() 185 | return 0 186 | 187 | 188 | def main(version): 189 | app = MainApplication(flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, version=version) 190 | app.run(sys.argv) 191 | -------------------------------------------------------------------------------- /flow/meson.build: -------------------------------------------------------------------------------- 1 | conf = configuration_data() 2 | conf.set('PYTHON', pymod.find_installation('python3').path()) 3 | conf.set('VERSION', meson.project_version() + v_suffix) 4 | conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir'))) 5 | conf.set('pkgdatadir', pkgdatadir) 6 | conf.set('profile', profile) 7 | conf.set('appname', app_name) 8 | conf.set('appid', app_id) 9 | 10 | configure_file( 11 | input: 'load.py.in', 12 | output: 'flow', 13 | configuration: conf, 14 | install: true, 15 | install_dir: get_option('bindir') 16 | ) 17 | 18 | sources = [ 19 | '__init__.py', 20 | 'main.py', 21 | ] 22 | 23 | install_data(sources, install_dir: moduledir) 24 | 25 | subdir('ui') 26 | subdir('utils') 27 | subdir('core') 28 | -------------------------------------------------------------------------------- /flow/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essmehdi/flow/2b70a567f74d7d00fce09f9c32e7595e1f01227e/flow/ui/__init__.py -------------------------------------------------------------------------------- /flow/ui/about.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | from flow.utils.app_info import AppInfo 3 | 4 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/about.ui") 5 | class AboutDialog(Gtk.AboutDialog): 6 | __gtype_name__ = 'AboutDialog' 7 | 8 | def __init__(self, version, **kwargs): 9 | super().__init__(**kwargs) 10 | self.set_version(version) 11 | self.set_program_name(AppInfo.name) -------------------------------------------------------------------------------- /flow/ui/browser_wait.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk, Adw, Gio 2 | from gettext import gettext as _ 3 | 4 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/browser_wait.ui") 5 | class BrowserWait(Adw.Window): 6 | __gtype_name__ = 'BrowserWait' 7 | 8 | stack = Gtk.Template.Child() 9 | url = Gtk.Template.Child() 10 | 11 | def __init__(self, **kwargs): 12 | super().__init__(**kwargs) 13 | 14 | self.confirm_id = None 15 | self.separate_id = None 16 | 17 | actions = Gio.SimpleActionGroup() 18 | 19 | self.cancel_action = Gio.SimpleAction(name='cancel') 20 | self.cancel_action.connect('activate', self.cancel) 21 | actions.add_action(self.cancel_action) 22 | 23 | self.confirm_action = Gio.SimpleAction(name='use-link') 24 | actions.add_action(self.confirm_action) 25 | 26 | self.separate_action = Gio.SimpleAction(name='separate') 27 | actions.add_action(self.separate_action) 28 | 29 | self.insert_action_group('bwait', actions) 30 | 31 | def show_confirm_dialog(self, url, confirm_callback, separate_callback): 32 | if self.confirm_id is not None: 33 | self.disconnect(self.confirm_id) 34 | self.confirm_action.connect('activate', confirm_callback) 35 | 36 | if self.separate_id is not None: 37 | self.disconnect(self.separate_id) 38 | self.separate_action.connect('activate', separate_callback) 39 | 40 | self.stack.set_visible_child_name('confirm') 41 | self.url.set_label(url) 42 | 43 | def cancel(self, *__): 44 | from ..core.controller import DownloadsController 45 | DownloadsController.get_instance().cancel_wait_for_link() -------------------------------------------------------------------------------- /flow/ui/download_edit.py: -------------------------------------------------------------------------------- 1 | import os 2 | from gi.repository import Gtk, Adw, Gio 3 | from gettext import gettext as _ 4 | 5 | from flow.core.controller import DownloadsController 6 | 7 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/download_edit.ui") 8 | class DownloadEdit(Adw.Window): 9 | __gtype_name__ = "DownloadPrompt" 10 | 11 | url_label = Gtk.Template.Child() 12 | filename_entry = Gtk.Template.Child() 13 | directory_button = Gtk.Template.Child() 14 | save_button_content = Gtk.Template.Child() 15 | cancel_button_content = Gtk.Template.Child() 16 | 17 | def __init__(self, initial, id, url, filename, directory, **kwargs): 18 | super().__init__(**kwargs) 19 | self.save_action = None 20 | # Check if it's download confirmation prompt 21 | self.initial = initial 22 | if self.initial: 23 | self.save_button_content.set_label(_("Download")) 24 | self.cancel_button_content.set_label(_("Cancel download")) 25 | self.save_button_content.set_icon_name("go-down-symbolic") 26 | # Register download ID 27 | self.id = id 28 | # Save original data 29 | self.og_filename = filename 30 | self.og_directory = directory 31 | self.filename_flag = False 32 | self.directory_flag = False 33 | # Set fields with original data 34 | self.directory_button.value = self.og_directory 35 | self.directory_button.connect('notify::value', self.on_directory_change) 36 | self.filename_entry.set_text(self.og_filename) 37 | self.url_label.set_label(url) 38 | self.url_label.set_tooltip_text(url) 39 | # Setup window actions 40 | self.setup_actions() 41 | self.refresh_save_action() 42 | 43 | @Gtk.Template.Callback() 44 | def filename_changed(self, entry, *_): 45 | if entry.get_text() != self.og_filename: 46 | self.filename_flag = True 47 | else: 48 | self.filename_flag = False 49 | self.refresh_save_action() 50 | 51 | def refresh_save_action(self): 52 | if self.save_action is not None: 53 | self.save_action.set_enabled(self.filename_flag or self.directory_flag or self.initial) 54 | 55 | def setup_actions(self): 56 | self.actions = Gio.SimpleActionGroup() 57 | # Save action 58 | self.save_action = Gio.SimpleAction(name='save') 59 | self.save_action.connect('activate', self.save) 60 | self.actions.add_action(self.save_action) 61 | # Cancel action 62 | self.cancel_action = Gio.SimpleAction(name='cancel') 63 | self.cancel_action.connect('activate', self.cancel_download if self.initial else self.cancel) 64 | self.actions.add_action(self.cancel_action) 65 | # Insert actions into window 66 | self.insert_action_group('dedit', self.actions) 67 | 68 | def on_directory_change(self, *_): 69 | selected_path = self.directory_button.value 70 | if os.path.normpath(selected_path) != os.path.normpath(self.og_directory): 71 | self.new_directory = selected_path 72 | self.directory_flag = True 73 | else: 74 | self.directory_flag = False 75 | self.refresh_save_action() 76 | 77 | def save(self, *_): 78 | DownloadsController.get_instance().edit( 79 | self.id, 80 | self.filename_entry.get_text() if self.filename_flag else None, 81 | self.new_directory if self.directory_flag else None 82 | ) 83 | self.destroy() 84 | 85 | def cancel_download(self, *_): 86 | DownloadsController.get_instance().delete(self.id) 87 | self.destroy() 88 | 89 | def cancel(self, *_): 90 | DownloadsController.get_instance().disable_edit_mode(self.id) 91 | self.destroy() 92 | -------------------------------------------------------------------------------- /flow/ui/download_item.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from gi.repository import Gtk, GLib, GObject, Gio, Gdk 3 | from gettext import gettext as _ 4 | from flow.ui.download_edit import DownloadEdit 5 | from flow.core.download import Download 6 | from flow.core.controller import DownloadsController 7 | from flow.utils.misc import convert_size, get_eta 8 | 9 | 10 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/download_item.ui") 11 | class DownloadItem(Gtk.ListBoxRow): 12 | __gtype_name__ = "DownloadItem" 13 | 14 | ERROR_MESSAGES = { 15 | Download.STATUS_REQUEST_ERROR: _("Could not make request"), 16 | Download.STATUS_CONNECTION_ERROR: _("Could not connect to server"), 17 | Download.STATUS_TIMEOUT_ERROR: _("Connection timed out"), 18 | Download.STATUS_RESUME_ERROR: _("Could not resume download") 19 | } 20 | 21 | DATE_TAG_TODAY = _("Today") 22 | DATE_TAG_YESTERDAY = _("Yesterday") 23 | DATE_TAG_LAST_WEEK = _("Last week") 24 | DATE_TAG_THIS_MONTH = _("This month") 25 | DATE_TAG_LAST_MONTH = _("Last month") 26 | DATE_TAG_THIS_YEAR = _("This year") 27 | DATE_TAG_LAST_YEAR = _("Last year") 28 | DATE_TAG_MORE_YEAR = _("More than a year") 29 | 30 | filename_text = Gtk.Template.Child() 31 | details_text = Gtk.Template.Child() 32 | open_folder_button = Gtk.Template.Child() 33 | cancel_button = Gtk.Template.Child() 34 | progress_bar = Gtk.Template.Child() 35 | actions_stack = Gtk.Template.Child() 36 | state_stack = Gtk.Template.Child() 37 | download_menu = Gtk.Template.Child() 38 | left_stack = Gtk.Template.Child() 39 | select_box = Gtk.Template.Child() 40 | file_icon = Gtk.Template.Child() 41 | 42 | id = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 43 | filename = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 44 | status = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 45 | size = GObject.Property(type=GObject.GType.from_name('glong'), default=-1, flags=GObject.ParamFlags.READWRITE) 46 | resumable = GObject.Property(type=bool, default=False, flags=GObject.ParamFlags.READWRITE) 47 | progress = GObject.Property(type=GObject.GType.from_name('gulong'), default=0, flags=GObject.ParamFlags.READWRITE) 48 | output_directory = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 49 | url = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 50 | category = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 51 | date_initiated = GObject.Property(type=GObject.GType.from_name('gulong'), default=0, flags=GObject.ParamFlags.READWRITE) 52 | date_started = GObject.Property(type=GObject.GType.from_name('gulong'), default=0, flags=GObject.ParamFlags.READWRITE) 53 | date_finished = GObject.Property(type=GObject.GType.from_name('gulong'), default=0, flags=GObject.ParamFlags.READWRITE) 54 | date_tag = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 55 | selected = GObject.Property(type=bool, default=False, flags=GObject.ParamFlags.READWRITE) 56 | selection_mode = GObject.Property(type=bool, default=False, flags=GObject.ParamFlags.READWRITE) 57 | 58 | def __init__(self, **kwargs): 59 | super().__init__(**kwargs) 60 | # Selection mode connect handler ID 61 | self.connect_id = None 62 | # Progress & speed data init 63 | self.last_checked = 0 64 | self.last_progress = 0 65 | self.speed = "0 B/s" 66 | self.eta = None 67 | # Context menu 68 | self.menu = Gtk.PopoverMenu.new_from_model(self.download_menu) 69 | #self.menu.set_has_arrow(False) 70 | self.menu.set_parent(self) 71 | # Setup actions 72 | self.setup_actions() 73 | # Setup drag & drop 74 | self.dnd = Gtk.DragSource.new() 75 | self.dnd.connect("prepare", self.prepare_dnd) 76 | self.add_controller(self.dnd) 77 | # Handle right click to open context menu 78 | self.right_click_handler = Gtk.GestureClick.new() 79 | self.right_click_handler.connect('pressed', self.handle_press) 80 | self.right_click_handler.set_button(3) 81 | self.add_controller(self.right_click_handler) 82 | # Handle double click to open file 83 | self.click_handler = Gtk.GestureClick.new() 84 | self.click_handler.connect_after('pressed', self.handle_press) 85 | self.add_controller(self.click_handler) 86 | # Handle properties changes 87 | self.connect('notify::filename', lambda *_: GLib.idle_add(self.on_filename_change)) 88 | self.connect('notify::status', lambda *_: GLib.idle_add(self.on_status_change)) 89 | self.connect('notify::progress', lambda *_: GLib.idle_add(self.on_progress_change)) 90 | self.connect('notify::size', lambda *_: GLib.idle_add(self.on_size_change)) 91 | self.connect('notify::resumable', lambda *_: GLib.idle_add(self.on_status_change)) 92 | self.connect_after('notify::selection-mode', self.on_selection_mode_change) 93 | self.select_box.set_sensitive(False) 94 | # Sync selected status with checkbox 95 | self.bind_property("selected", self.select_box, "active", GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL) 96 | 97 | def on_selected_change(self, *_): 98 | if self.selected: 99 | DownloadsController.get_instance().select(self.id) 100 | else: 101 | DownloadsController.get_instance().unselect(self.id) 102 | 103 | def on_selection_mode_change(self, *_): 104 | if self.status == Download.STATUS_DONE: 105 | if self.selection_mode: 106 | self.left_stack.set_visible_child_name('select_mode') 107 | self.connect_id = self.connect('notify::selected', self.on_selected_change) 108 | else: 109 | if self.connect_id is not None: 110 | self.disconnect(self.connect_id) 111 | self.connect_id = None 112 | self.selected = False 113 | self.left_stack.set_visible_child_name('action_mode') 114 | 115 | def on_filename_change(self, *__): 116 | if self.filename == "": 117 | self.filename_text.set_label(_("Loading...")) 118 | else: 119 | self.filename_text.set_label(self.filename) 120 | 121 | def on_status_change(self, *__): 122 | self.progress_bar.set_visible(self.size > 0 and self.status in ['downloading', 'paused']) 123 | if self.status == "init": 124 | self.state_stack.set_visible_child_name('pause') 125 | self.details_text.set_label(_("Please wait")) 126 | self.actions_stack.set_visible_child_name('empty') 127 | self.actions_activator('delete') 128 | elif self.status == "downloading": 129 | self.state_stack.set_visible_child_name('pause') 130 | self.actions_stack.set_visible_child_name('cancel') 131 | self.actions_activator('pause' if self.resumable else '', 'delete', 'edit') 132 | elif self.status == "paused": 133 | self.last_checked = 0 134 | self.state_stack.set_visible_child_name('resume' if self.resumable else 'restart') 135 | self.actions_stack.set_visible_child_name('cancel') 136 | self.actions_activator('restart', 'delete') 137 | elif "error" in self.status: 138 | self.state_stack.set_visible_child_name('restart') 139 | self.actions_stack.set_visible_child_name('cancel') 140 | self.details_text.set_label(DownloadItem.ERROR_MESSAGES.get(self.status, _("An error occured"))) 141 | self.actions_activator('restart', 'delete', 'resume-with-link') 142 | elif self.status == "moving": 143 | self.details_text.set_label(_("Moving file...")) 144 | self.state_stack.set_visible_child_name('pause') 145 | self.actions_stack.set_visible_child_name('cancel') 146 | self.actions_activator() 147 | elif self.status == "done": 148 | self.state_stack.set_visible_child_name('done') 149 | self.actions_stack.set_visible_child_name('open') 150 | self.actions_activator('open-folder', 'open-file', 'delete', 'delete-with-file') 151 | self.details_text.set_label( 152 | (convert_size(self.size) if self.size > 0 else '') + (" • " if self.category and self.size > 0 else '') + self.category 153 | ) 154 | self.set_selectable(True) 155 | self.file_icon.set_from_gicon(Gio.content_type_get_icon(self.get_file_mimetype())) 156 | 157 | def actions_activator(self, *actions): 158 | for action in self.actions.list_actions(): 159 | self.actions.lookup_action(action).set_enabled(action in actions or action == 'copy-url') 160 | 161 | def update_details_text(self): 162 | text = f"{convert_size(self.progress)}" + (f" / {convert_size(self.size)}" if self.size > 0 else "") + (f" • {self.eta}" if self.eta and self.status == Download.STATUS_STARTED else "") 163 | if self.speed: 164 | if self.speed == "0 B/s" and self.status == Download.STATUS_PAUSED: 165 | text += " • Paused" 166 | else: 167 | text += f" • { self.speed }" 168 | self.details_text.set_label(text) 169 | 170 | def on_progress_change(self, *_): 171 | # Speed & ETA 172 | current_time = GLib.get_monotonic_time() 173 | if self.last_checked == 0: 174 | if self.status == "downloading" or self.status == "paused": 175 | self.update_details_text() 176 | self.last_checked = current_time 177 | self.last_progress = self.progress 178 | time_delta = current_time - self.last_checked 179 | if time_delta >= 10**6: 180 | # Amount of data download in a second 181 | progress_delta = self.progress - self.last_progress 182 | # Update the progess 183 | if self.size > 0: 184 | self.progress_bar.set_fraction(float(self.progress / self.size)) 185 | self.eta = get_eta(progress_delta, self.size - self.progress) 186 | self.speed = f"{convert_size(progress_delta)}/s" if progress_delta > 0 else "0 B/s" 187 | if self.status == "downloading" or self.status == "paused": 188 | self.update_details_text() 189 | self.last_progress = self.progress 190 | self.last_checked = current_time 191 | 192 | def on_size_change(self, *_): 193 | self.progress_bar.set_visible(self.size > 0 and self.status in ('downloading', 'paused')) 194 | 195 | def handle_press(self, controller, clicks, x, y): 196 | if controller.get_button() == 3: 197 | # TODO: Fix weird warnings 198 | pointing_position = Gdk.Rectangle(x, y, 1, 1) 199 | pointing_position.x = x 200 | pointing_position.y = y 201 | pointing_position.height = 1 202 | pointing_position.width = 1 203 | self.menu.set_pointing_to(pointing_position) 204 | self.menu.show() 205 | elif controller.get_button() == 1: 206 | if clicks == 2 and not self.selection_mode: 207 | if self.status == Download.STATUS_DONE: 208 | self.actions.lookup_action('open-file').activate() 209 | else: 210 | self.actions.lookup_action('edit').activate() 211 | elif clicks == 1 and self.selection_mode: 212 | self.selected = not self.selected 213 | 214 | def open_folder(self, *_): 215 | DownloadsController.get_instance().open(self.id, True) 216 | 217 | def prepare_dnd(self, *_): 218 | if self.status == 'done' and not self.selection_mode: 219 | file = DownloadsController.get_instance().get_file(self.id) 220 | if file: 221 | return Gdk.ContentProvider.new_union([ 222 | Gdk.ContentProvider.new_for_value(file), 223 | Gdk.ContentProvider.new_for_value(file.get_path()), 224 | Gdk.ContentProvider.new_for_value(file.get_uri()), 225 | Gdk.ContentProvider.new_for_bytes("text/uri-list", GLib.Bytes.new(file.get_uri().encode())) 226 | ]) 227 | 228 | def setup_actions(self): 229 | self.actions = Gio.SimpleActionGroup.new() 230 | actions_list = [ 231 | { 232 | "name": 'copy-url', 233 | "activate": lambda _, __, row: DownloadsController.get_instance().copy_url(row.id) 234 | }, 235 | { 236 | "name": 'open-file', 237 | "activate": lambda _, __, row: DownloadsController.get_instance().open(row.id, False) 238 | }, 239 | { 240 | "name": 'open-folder', 241 | "activate": lambda _, __, row: DownloadsController.get_instance().open(row.id, True) 242 | }, 243 | { 244 | "name": 'delete', 245 | "activate": lambda _, __, row: DownloadsController.get_instance().delete(row.id) 246 | }, 247 | { 248 | "name": 'delete-with-file', 249 | "activate": lambda _, __, row: DownloadsController.get_instance().delete(row.id, True) 250 | }, 251 | { 252 | "name": 'pause', 253 | "activate": lambda _, __, row: DownloadsController.get_instance().pause(row.id) 254 | }, 255 | { 256 | "name": 'restart', 257 | "activate": lambda _, __, row: DownloadsController.get_instance().restart(row.id) 258 | }, 259 | { 260 | "name": 'edit', 261 | "activate": self.show_edit_window 262 | }, 263 | { 264 | "name": 'resume-with-link', 265 | "activate": lambda _, __, row: DownloadsController.get_instance().resume_with_link(row.id) 266 | } 267 | ] 268 | for action in actions_list: 269 | _action = Gio.SimpleAction.new(action.get('name'), None) 270 | _action.connect('activate', action.get('activate'), self) 271 | self.actions.add_action(_action) 272 | self.insert_action_group('download', self.actions) 273 | 274 | def get_file_mimetype(self): 275 | return Gio.content_type_guess(self.filename)[0] 276 | 277 | def show_edit_window(self, *_): 278 | DownloadsController.get_instance().enable_edit_mode(self.id) 279 | -------------------------------------------------------------------------------- /flow/ui/file_chooser_button.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk, GObject, Pango 2 | from gettext import gettext as _ 3 | import os 4 | 5 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/file_chooser_button.ui") 6 | class FileChooserButton(Gtk.Button): 7 | __gtype_name__ = 'FileChooserButton' 8 | 9 | button_content = Gtk.Template.Child() 10 | value = GObject.Property(type=str, default="", flags=GObject.ParamFlags.READWRITE) 11 | 12 | def __init__(self, initial_value=None, **kwargs): 13 | super().__init__(**kwargs) 14 | if initial_value: 15 | self.value = initial_value 16 | else: 17 | self.value = "" 18 | self.connect('notify::value', self.on_value_change) 19 | self.get_button_label().set_ellipsize(Pango.EllipsizeMode.START) 20 | 21 | def get_button_label(self): 22 | current = self.button_content.get_first_child() 23 | while current is not None: 24 | if GObject.type_is_a(current, Gtk.Label): 25 | return current 26 | current = current.get_next_sibling() 27 | 28 | def on_value_change(self, *__): 29 | self.button_content.set_label(self.value if self.value else _("Choose a directory")) 30 | self.set_tooltip_text(self.value) 31 | 32 | @Gtk.Template.Callback() 33 | def button_clicked(self, *__): 34 | root = self.get_root() 35 | chooser = Gtk.FileChooserDialog(transient_for=root, application=root.get_application()) 36 | chooser.set_action(Gtk.FileChooserAction.SELECT_FOLDER) 37 | chooser.set_title(_("Choose a directory")) 38 | chooser.add_buttons( 39 | _("Cancel"), Gtk.ResponseType.CANCEL, 40 | _("Select"), Gtk.ResponseType.OK) 41 | chooser.set_modal(True) 42 | chooser.get_widget_for_response(response_id=Gtk.ResponseType.OK).get_style_context().add_class(class_name="suggested-action") 43 | chooser.connect("response", self.path_chooser_response) 44 | chooser.show() 45 | 46 | def path_chooser_response(self, chooser, response): 47 | if response == Gtk.ResponseType.OK: 48 | selected_path = chooser.get_file().get_path() 49 | if os.access(selected_path, os.W_OK): 50 | self.value = selected_path 51 | else: 52 | error_dialog = Gtk.MessageDialog( 53 | transient_for = self.get_root(), 54 | message_type = Gtk.MessageType.ERROR, 55 | buttons = Gtk.ButtonsType.OK, 56 | text = _("Permission error"), 57 | secondary_text = _("{} is not writable").format(selected_path) 58 | ) 59 | error_dialog.connect("response", lambda *__: error_dialog.destroy()) 60 | error_dialog.set_modal(True) 61 | error_dialog.show() 62 | chooser.destroy() 63 | -------------------------------------------------------------------------------- /flow/ui/meson.build: -------------------------------------------------------------------------------- 1 | moduledir = pkgdatadir / 'flow' / 'ui' 2 | 3 | sources = [ 4 | '__init__.py', 5 | 'download_item.py', 6 | 'download_edit.py', 7 | 'url_prompt.py', 8 | 'file_chooser_button.py', 9 | 'browser_wait.py', 10 | 'shortcuts.py', 11 | 'about.py' 12 | ] 13 | 14 | install_data(sources, install_dir: moduledir) 15 | subdir('preferences') -------------------------------------------------------------------------------- /flow/ui/preferences/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essmehdi/flow/2b70a567f74d7d00fce09f9c32e7595e1f01227e/flow/ui/preferences/__init__.py -------------------------------------------------------------------------------- /flow/ui/preferences/category_editor.py: -------------------------------------------------------------------------------- 1 | from flow.core.settings import Settings 2 | from gi.repository import Gtk, Adw, Gio 3 | from gettext import gettext as _ 4 | 5 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/preferences/category_editor.ui") 6 | class CategoryEditor(Adw.Window): 7 | __gtype_name__ = "CategoryEditor" 8 | 9 | name_entry = Gtk.Template.Child() 10 | path_button = Gtk.Template.Child() 11 | extensions_entry = Gtk.Template.Child() 12 | 13 | def __init__(self, category, settings, callback, **kwargs): 14 | super().__init__(**kwargs) 15 | # If category is not supplied, editor is in "Add Mode" 16 | if category: 17 | self.category = category 18 | self.settings = settings 19 | self.name_entry.set_text(category) 20 | self.path_button.value = settings["path"] 21 | self.extensions_entry.set_text(settings["extensions"]) 22 | self.add_mode = False 23 | else: 24 | self.add_mode = True 25 | self.callback = callback 26 | # Actions 27 | self.actions = Gio.SimpleActionGroup.new() 28 | self.save_edit_action = Gio.SimpleAction.new('save') 29 | self.save_edit_action.connect('activate', self.save_edit) 30 | self.actions.add_action(self.save_edit_action) 31 | self.cancel_edit_action = Gio.SimpleAction.new('cancel') 32 | self.cancel_edit_action.connect('activate', self.cancel_edit) 33 | self.actions.add_action(self.cancel_edit_action) 34 | self.insert_action_group('cedit', self.actions) 35 | 36 | # @Gtk.Template.Callback() 37 | # def path_button_clicked(self, *_): 38 | # chooser = Gtk.FileChooserDialog(transient_for=self, application=self.get_application()) 39 | # chooser.set_action(Gtk.FileChooserAction.SELECT_FOLDER) 40 | # chooser.set_title("Choose a directory") 41 | # chooser.add_buttons( 42 | # "Cancel", Gtk.ResponseType.CANCEL, 43 | # "Select", Gtk.ResponseType.OK) 44 | # chooser.set_modal(True) 45 | # chooser.get_widget_for_response(response_id=Gtk.ResponseType.OK).get_style_context().add_class(class_name="suggested-action") 46 | # chooser.connect("response", self.path_chooser_response) 47 | # chooser.show() 48 | 49 | # def path_chooser_response(self, chooser, response): 50 | # if response == Gtk.ResponseType.OK: 51 | # selected_path = chooser.get_file().get_path() 52 | # if os.access(selected_path, os.W_OK): 53 | # self.current_path = selected_path 54 | # self.path_button_content.set_label(self.current_path) 55 | # else: 56 | # error_dialog = Gtk.MessageDialog( 57 | # transient_for = self, 58 | # message_type = Gtk.MessageType.ERROR, 59 | # buttons = Gtk.ButtonsType.OK, 60 | # text = _("Permission error"), 61 | # secondary_text = _("{} is not writable").format(selected_path) 62 | # ) 63 | # error_dialog.connect("response", lambda *_: error_dialog.destroy()) 64 | # error_dialog.set_modal(True) 65 | # error_dialog.show() 66 | # chooser.destroy() 67 | 68 | @Gtk.Template.Callback() 69 | def entry_edit(self, entry): 70 | if not len(entry.get_text()): 71 | self.set_empty_error(entry) 72 | else: 73 | self.clear_empty_error(entry) 74 | 75 | def set_empty_error(self, widget): 76 | widget.add_css_class("error") 77 | widget.set_tooltip_text(_("Must not be empty")) 78 | 79 | def clear_empty_error(self, widget): 80 | widget.remove_css_class("error") 81 | widget.set_tooltip_text("") 82 | 83 | def save_edit(self, *args): 84 | new_category = self.name_entry.get_text() 85 | new_settings = { 86 | "path": self.path_button.value, 87 | "extensions": self.extensions_entry.get_text().strip() 88 | } 89 | if not len(new_category): 90 | self.name_entry.grab_focus() 91 | self.set_empty_error(self.name_entry) 92 | return 93 | if not len(new_settings["path"]): 94 | self.path_button.grab_focus() 95 | self.set_empty_error(self.path_button) 96 | return 97 | if not len(new_settings["extensions"]): 98 | self.extensions_entry.grab_focus() 99 | self.set_empty_error(self.name_entry) 100 | return 101 | if self.add_mode: 102 | Settings.get().add_category( 103 | new_category, 104 | new_settings["path"], 105 | new_settings["extensions"], 106 | ) 107 | else: 108 | Settings.get().set_category_settings( 109 | new_category, 110 | new_settings["path"], 111 | new_settings["extensions"], 112 | self.category if new_category != self.category else None 113 | ) 114 | if self.callback: 115 | self.callback(new_category, new_settings) 116 | self.destroy() 117 | 118 | def cancel_edit(self, *_): 119 | self.destroy() 120 | -------------------------------------------------------------------------------- /flow/ui/preferences/category_row.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flow.core.settings import Settings 4 | from flow.ui.preferences.category_editor import CategoryEditor 5 | from gi.repository import Gtk, Adw 6 | from gettext import gettext as _ 7 | 8 | 9 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/preferences/category_row.ui") 10 | class CategoryRow(Adw.ActionRow): 11 | __gtype_name__ = "CategoryRow" 12 | 13 | def __init__(self, category, settings, **kwargs): 14 | super().__init__(**kwargs) 15 | self.category = category 16 | self.settings = settings 17 | self.refresh_ui() 18 | 19 | def refresh_ui(self): 20 | self.set_title(self.category) 21 | self.set_subtitle(self.settings.get("path", "-empty-")) 22 | 23 | @Gtk.Template.Callback() 24 | def activate_handler(self, *_): 25 | self.editor = CategoryEditor(self.category, self.settings, self.update_ui, transient_for=self.get_root(), application=self.get_root().get_application()) 26 | self.editor.show() 27 | 28 | @Gtk.Template.Callback() 29 | def delete_category(self, *__): 30 | confirm_dialog = Gtk.MessageDialog( 31 | transient_for = self.get_root(), 32 | message_type = Gtk.MessageType.WARNING, 33 | buttons = Gtk.ButtonsType.YES_NO, 34 | text = _("Are you sure?"), 35 | secondary_text = _("This action cannot be undone. You will permanently delete this category.") 36 | ) 37 | confirm_dialog.connect("response", self.confirm_delete_category) 38 | confirm_dialog.set_modal(True) 39 | confirm_dialog.show() 40 | 41 | def confirm_delete_category(self, dialog, response): 42 | if response == Gtk.ResponseType.YES: 43 | Settings.get().remove_category(self.category) 44 | dialog.destroy() 45 | self.get_parent().remove(self) 46 | else: 47 | dialog.destroy() 48 | 49 | def update_ui(self, category, settings): 50 | self.category = category 51 | self.settings = settings 52 | self.refresh_ui() 53 | -------------------------------------------------------------------------------- /flow/ui/preferences/meson.build: -------------------------------------------------------------------------------- 1 | moduledir = pkgdatadir / 'flow' / 'ui' / 'preferences' 2 | 3 | sources = [ 4 | '__init__.py', 5 | 'window.py', 6 | 'category_row.py', 7 | 'category_editor.py' 8 | ] 9 | 10 | install_data(sources, install_dir: moduledir) -------------------------------------------------------------------------------- /flow/ui/preferences/window.py: -------------------------------------------------------------------------------- 1 | from .category_editor import CategoryEditor 2 | 3 | from flow.core.settings import Settings 4 | from flow.ui.preferences.category_row import CategoryRow 5 | from flow.ui.file_chooser_button import FileChooserButton # Necessary for Gtk.Builder 6 | 7 | from gi.repository import Gtk, Adw, Gio 8 | 9 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/preferences/window.ui") 10 | class PreferencesWindow(Adw.PreferencesWindow): 11 | __gtype_name__ = "PreferencesWindow" 12 | 13 | categories_group = Gtk.Template.Child() 14 | connection_timeout_spin = Gtk.Template.Child() 15 | connection_proxy = Gtk.Template.Child() 16 | connection_proxy_address_entry = Gtk.Template.Child() 17 | connection_proxy_port_spin = Gtk.Template.Child() 18 | user_agent_entry = Gtk.Template.Child() 19 | force_dark_toggle = Gtk.Template.Child() 20 | fallback_directory_button = Gtk.Template.Child() 21 | 22 | def __init__(self, **kwargs): 23 | super().__init__(**kwargs) 24 | for binding in self.get_bindings(): 25 | Settings.get().bind(binding.get('key'), binding.get('widget'), binding.get('property'), Gio.SettingsBindFlags.DEFAULT) 26 | self.force_dark_toggle.connect('state-set', self.dark_mode_handler) 27 | self.populate_categories() 28 | 29 | def dark_mode_handler(self, _, state): 30 | style_manager = Adw.StyleManager.get_default() 31 | if state: 32 | style_manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK) 33 | else: 34 | style_manager.set_color_scheme(Adw.ColorScheme.PREFER_LIGHT) 35 | 36 | def populate_categories(self): 37 | self.categories = Settings.get().categories 38 | for category, settings in self.categories.items(): 39 | self.categories_group.add(CategoryRow(category, settings)) 40 | self.append_add_button() 41 | 42 | def append_add_button(self): 43 | builder = Gtk.Builder() 44 | builder.add_from_resource("/com/github/essmehdi/flow/layout/preferences/add_row.ui") 45 | self.add_row = builder.get_object("add_row") 46 | self.add_button = builder.get_object("add_button") 47 | self.add_button.connect("clicked", self.add_handler) 48 | self.categories_group.add(self.add_row) 49 | 50 | def update_ui(self, category, settings): 51 | self.categories_group.remove(self.add_row) 52 | self.categories_group.add(CategoryRow(category, settings)) 53 | self.categories_group.add(self.add_row) 54 | 55 | def add_handler(self, *_): 56 | self.editor = CategoryEditor(None, None, self.update_ui, transient_for=self, application=self.get_application()) 57 | self.editor.show() 58 | 59 | def get_bindings(self): 60 | return [ 61 | { 62 | "widget": self.connection_timeout_spin, 63 | "key": "connection-timeout", 64 | "property": "value" 65 | }, 66 | { 67 | "widget": self.connection_proxy, 68 | "key": "connection-proxy", 69 | "property": "enable-expansion" 70 | }, 71 | { 72 | "widget": self.connection_proxy_address_entry, 73 | "key": "connection-proxy-address", 74 | "property": "text" 75 | }, 76 | { 77 | "widget": self.connection_proxy_port_spin, 78 | "key": "connection-proxy-port", 79 | "property": "value" 80 | }, 81 | { 82 | "widget": self.user_agent_entry, 83 | "key": "user-agent", 84 | "property": "text" 85 | }, 86 | { 87 | "widget": self.force_dark_toggle, 88 | "key": "force-dark", 89 | "property": "active" 90 | }, 91 | { 92 | "widget": self.fallback_directory_button, 93 | "key": "fallback-directory", 94 | "property": "value" 95 | } 96 | ] -------------------------------------------------------------------------------- /flow/ui/shortcuts.py: -------------------------------------------------------------------------------- 1 | from operator import imod 2 | from gi.repository import Gtk 3 | 4 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/shortcuts.ui") 5 | class Shortcuts(Gtk.ShortcutsWindow): 6 | __gtype_name__ = "Shortcuts" 7 | 8 | def __init__(self, **kwargs): 9 | super().__init__(**kwargs) -------------------------------------------------------------------------------- /flow/ui/url_prompt.py: -------------------------------------------------------------------------------- 1 | import validators 2 | from gi.repository import Gtk, Adw, Gio 3 | 4 | from flow.core.controller import DownloadsController 5 | 6 | @Gtk.Template(resource_path="/com/github/essmehdi/flow/layout/url_prompt.ui") 7 | class URLPrompt(Adw.Window): 8 | 9 | __gtype_name__ = "URLPrompt" 10 | 11 | url_entry = Gtk.Template.Child() 12 | confirm_button = Gtk.Template.Child() 13 | error_text = Gtk.Template.Child() 14 | url_value = "" 15 | 16 | def __init__(self, **kwargs): 17 | super().__init__(**kwargs) 18 | self.setup_actions() 19 | self.url_entry.set_text(self.url_value) 20 | 21 | def setup_actions(self): 22 | self.actions = Gio.SimpleActionGroup.new() 23 | # Confirm action 24 | self.confirm_action = Gio.SimpleAction.new('confirm', None) 25 | self.confirm_action.connect('activate', self.confirm) 26 | self.confirm_action.set_enabled(False) 27 | self.actions.add_action(self.confirm_action) 28 | # Cancel action 29 | self.cancel_action = Gio.SimpleAction.new('cancel', None) 30 | self.cancel_action.connect('activate', self.cancel) 31 | self.actions.add_action(self.cancel_action) 32 | self.insert_action_group('prompt', self.actions) 33 | 34 | @Gtk.Template.Callback() 35 | def url_text_changed(self, *_): 36 | if self.error_text.is_visible: 37 | self.error_text.set_visible(False) 38 | self.url_value = self.url_entry.get_text() 39 | if not validators.url(self.url_value): 40 | self.confirm_action.set_enabled(False) 41 | else: 42 | self.confirm_action.set_enabled(True) 43 | 44 | def cancel(self, *_): 45 | self.destroy() 46 | 47 | def confirm(self, *_): 48 | DownloadsController.get_instance().add_from_url(self.url_value) 49 | self.destroy() 50 | 51 | -------------------------------------------------------------------------------- /flow/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essmehdi/flow/2b70a567f74d7d00fce09f9c32e7595e1f01227e/flow/utils/__init__.py -------------------------------------------------------------------------------- /flow/utils/app_info.py: -------------------------------------------------------------------------------- 1 | class AppInfo: 2 | profile = "" 3 | name = "" 4 | app_id = "" -------------------------------------------------------------------------------- /flow/utils/meson.build: -------------------------------------------------------------------------------- 1 | moduledir = pkgdatadir / 'flow' / 'utils' 2 | 3 | sources = [ 4 | '__init__.py', 5 | 'misc.py', 6 | 'notifier.py', 7 | 'toaster.py', 8 | 'app_info.py' 9 | ] 10 | 11 | install_data(sources, install_dir: moduledir) -------------------------------------------------------------------------------- /flow/utils/misc.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import math 4 | 5 | from gi.repository import Gio, GLib 6 | 7 | def convert_size(size): 8 | if size == 0: 9 | return "0 B" 10 | size_names = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") 11 | index = int(math.floor(math.log(size, 1024))) 12 | unit = math.pow(1024, index) 13 | result = round(size / unit, 2) 14 | return f"{result} {size_names[index]}" 15 | 16 | def get_filename_from_url(url): 17 | from urllib.parse import unquote 18 | filename = unquote(url).split("/")[-1] 19 | if len(filename) >= 200: 20 | filename = filename[-1:-100:-1].strip() 21 | return filename 22 | 23 | def get_eta(speed, size): 24 | if speed > 0: 25 | eta = size // speed 26 | seconds = eta % 60 27 | minutes = (eta // 60) % 60 28 | hours = eta // 3600 29 | return f"{hours}:{str(minutes).zfill(2)}:{str(seconds).zfill(2)}" 30 | return "" 31 | 32 | def file_extension_match(filename, extensions): 33 | extensions_regex = "(?:" + extensions.strip().replace('.', '\.').replace(' ', '|') + ")$" 34 | return False if re.search(extensions_regex, filename) is None else True 35 | 36 | def create_download_temp(name): 37 | temp_dir = os.path.join(GLib.get_home_dir(), '.flow/files') 38 | # Create folder if doesn't exist 39 | if not os.path.exists(temp_dir): 40 | os.makedirs(temp_dir) 41 | return Gio.File.new_for_path(os.path.join(temp_dir, name)) 42 | 43 | -------------------------------------------------------------------------------- /flow/utils/notifier.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from gi.repository import Gio 3 | 4 | class Notifier(): 5 | 6 | app = None 7 | 8 | @staticmethod 9 | def init(app): 10 | Notifier.app = app 11 | 12 | @staticmethod 13 | def notify(id, title = "", body = "", icon = None, urgency = Gio.NotificationPriority.NORMAL): 14 | logging.debug(f"Sending notification: {id} - {title} - {body}") 15 | if Notifier.app is None: 16 | logging.error("Application not launched") 17 | return 18 | notification = Gio.Notification() 19 | notification.set_title(title) 20 | notification.set_body(body) 21 | notification.set_icon(Gio.ThemedIcon.new(icon)) 22 | notification.set_priority(urgency) 23 | notification.set_default_action('app.raise') 24 | Notifier.app.send_notification(id, notification) 25 | -------------------------------------------------------------------------------- /flow/utils/toaster.py: -------------------------------------------------------------------------------- 1 | import gi 2 | import logging 3 | 4 | gi.require_version("Gtk", "4.0") 5 | gi.require_version("Adw", "1") 6 | 7 | from gi.repository import Adw 8 | 9 | 10 | """ 11 | A Toaster service to show a notification from every component of the app 12 | """ 13 | 14 | class Toaster(): 15 | 16 | overlay = None 17 | 18 | @staticmethod 19 | def register_overlay(overlay): 20 | if isinstance(overlay, Adw.ToastOverlay): 21 | Toaster.overlay = overlay 22 | else: 23 | logging.error("The object passed is not a valid overlay") 24 | 25 | @staticmethod 26 | def show(text, action = None, timeout = 3, priority = Adw.ToastPriority.NORMAL): 27 | if not Toaster.overlay: 28 | logging.error("No overlay registered") 29 | else: 30 | toast = Adw.Toast.new(text) 31 | if action is not None: 32 | toast.set_button_label(action.get('label', 'Action')) 33 | toast.set_button_action(action.get('name')) 34 | toast.set_timeout(timeout) 35 | toast.set_priority(priority) 36 | Toaster.overlay.add_toast(toast) -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('flow', version: '0.1.0',) 2 | 3 | fs = import('fs') 4 | 5 | profile = get_option('profile') 6 | app_name = 'Flow' 7 | app_id = 'com.github.essmehdi.flow' 8 | v_suffix = '' 9 | 10 | if profile == 'development' 11 | app_name = app_name + ' (Devel)' 12 | app_id = app_id + '.devel' 13 | if fs.is_dir('.git') 14 | git_rev = run_command('git', 'rev-parse', '--short', 'HEAD', check: false).stdout().strip() 15 | if git_rev != '' 16 | v_suffix = '-@0@'.format(git_rev) 17 | endif 18 | endif 19 | endif 20 | 21 | pymod = import('python') 22 | pymod.find_installation('python3') 23 | 24 | dependency('gtk4') 25 | dependency('libadwaita-1') 26 | dependency('libcurl') 27 | 28 | # Separate release data from devel data 29 | suffix = '' 30 | if profile == 'development' 31 | suffix = '-devel' 32 | endif 33 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name() + suffix) 34 | moduledir = join_paths(pkgdatadir, 'flow') 35 | 36 | subdir('resources') 37 | subdir('flow') 38 | subdir('extension') 39 | subdir('po') 40 | 41 | import('gnome').post_install( 42 | glib_compile_schemas: true, 43 | gtk_update_icon_cache: true, 44 | update_desktop_database: true 45 | ) -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option( 2 | 'profile', 3 | type: 'combo', 4 | choices: ['release', 'development'], 5 | value: 'release' 6 | ) -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | ar -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | resources/layout/main.ui 2 | resources/layout/url_prompt.ui 3 | resources/layout/download_item.ui 4 | resources/layout/download_edit.ui 5 | resources/layout/about.ui 6 | resources/layout/preferences/window.ui 7 | resources/layout/preferences/category_row.ui 8 | resources/layout/preferences/add_row.ui 9 | resources/layout/preferences/category_editor.ui 10 | 11 | src/components/download_edit.py 12 | src/components/download_item.py 13 | src/components/preferences/category_editor.py 14 | src/controller.py 15 | src/download.py 16 | src/main.py 17 | -------------------------------------------------------------------------------- /po/ar.po: -------------------------------------------------------------------------------- 1 | # POT File for Flow. 2 | # Copyright (C) 2022 Mehdi ESSALEHI 3 | # This file is distributed under the same license as the flow package. 4 | # Mehdi ESSALEHI , 2022. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: flow\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-03-03 19:09+0100\n" 12 | "Last-Translator: Mehdi ESSALEHI \n" 13 | "Language-Team: Mehdi ESSALEHI \n" 14 | "Language: Arabic\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | 19 | #: resources/layout/main.ui:89 20 | msgid "Running" 21 | msgstr "جارية" 22 | 23 | #: resources/layout/main.ui:113 24 | msgid "Finished" 25 | msgstr "مكتملة" 26 | 27 | #: resources/layout/main.ui:149 28 | msgid "No downloads yet" 29 | msgstr "لا يوجد تحميل بعد" 30 | 31 | #: resources/layout/main.ui:151 32 | msgid "Add a download from URL or use your browser extension to catch one" 33 | msgstr "أضف تحميلا من رابط أو استعمل متصفحك" 34 | 35 | #: resources/layout/main.ui:158 36 | msgid "Add from URL" 37 | msgstr "تحميل من رابط" 38 | 39 | #: resources/layout/main.ui:178 40 | msgid "Preferences" 41 | msgstr "الإعدادات" 42 | 43 | #: resources/layout/main.ui:182 44 | msgid "About" 45 | msgstr "حول" 46 | 47 | #: resources/layout/url_prompt.ui:6 48 | msgid "Enter URL" 49 | msgstr "أدخل الرابط" 50 | 51 | #: resources/layout/url_prompt.ui:33 52 | msgid "Invalid URL" 53 | msgstr "رابط باطل" 54 | 55 | #: resources/layout/url_prompt.ui:52 resources/layout/download_edit.ui:117 56 | #: resources/layout/preferences/category_editor.ui:83 57 | #: src/components/download_edit.py:76 58 | #: src/components/preferences/category_editor.py:48 59 | msgid "Cancel" 60 | msgstr "إلغاء" 61 | 62 | #: resources/layout/url_prompt.ui:58 63 | msgid "Add" 64 | msgstr "إضافة" 65 | 66 | #: resources/layout/download_item.ui:199 67 | msgid "Edit" 68 | msgstr "تحرير" 69 | 70 | #: resources/layout/download_item.ui:205 71 | msgid "Resume with new link" 72 | msgstr "الإستئناف برابط جديد" 73 | 74 | #: resources/layout/download_item.ui:209 75 | msgid "Copy download link" 76 | msgstr "نسخ رابط التحميل" 77 | 78 | #: resources/layout/download_item.ui:216 79 | msgid "Open file" 80 | msgstr "فتح الملف" 81 | 82 | #: resources/layout/download_item.ui:221 83 | msgid "Open containing folder" 84 | msgstr "فتح المجلد الحامل للملف" 85 | 86 | #: resources/layout/download_edit.ui:38 87 | msgid "URL" 88 | msgstr "الرابط" 89 | 90 | #: resources/layout/download_edit.ui:63 91 | msgid "File name" 92 | msgstr "إسم الملف" 93 | 94 | #: resources/layout/download_edit.ui:85 95 | #: resources/layout/preferences/category_editor.ui:43 96 | msgid "Directory" 97 | msgstr "الدليل" 98 | 99 | #: resources/layout/download_edit.ui:128 100 | #: resources/layout/preferences/category_editor.ui:89 101 | msgid "Save" 102 | msgstr "حفظ" 103 | 104 | #: resources/layout/about.ui:5 105 | #, fuzzy 106 | msgid "About Flow" 107 | msgstr "حول أتاي" 108 | 109 | #: resources/layout/about.ui:6 110 | msgid "Mehdi ESSALEHI" 111 | msgstr "المهدي الصالحي" 112 | 113 | #: resources/layout/about.ui:7 114 | msgid "A download manager for GNOME" 115 | msgstr "مدير التحميلات لGNOME" 116 | 117 | #: resources/layout/about.ui:11 118 | msgid "Copyright Ⓒ 2022 - Mehdi ESSALEHI" 119 | msgstr "جميع الحقوق محفوظة Ⓒ 2022 - المهدي الصالحي" 120 | 121 | #: resources/layout/preferences/window.ui:11 122 | msgid "General" 123 | msgstr "إعدادات عامة" 124 | 125 | #: resources/layout/preferences/window.ui:14 126 | msgid "Appearance" 127 | msgstr "المظهر" 128 | 129 | #: resources/layout/preferences/window.ui:17 130 | msgid "Force dark mode" 131 | msgstr "فرض الوضع المظلم" 132 | 133 | #: resources/layout/preferences/window.ui:18 134 | msgid "A quick toggle to force dark mode on the app" 135 | msgstr "مفتاح سريع لفرض الوضع المظلم" 136 | 137 | #: resources/layout/preferences/window.ui:31 138 | msgid "Downloads" 139 | msgstr "التحميلات" 140 | 141 | #: resources/layout/preferences/window.ui:34 142 | msgid "Timeout" 143 | msgstr "مهلة الإتصال" 144 | 145 | #: resources/layout/preferences/window.ui:35 146 | msgid "Time to give to a connection before dropping" 147 | msgstr "المهلة المسموحة للإتصال قبل الإعلان عن الفشل" 148 | 149 | #: resources/layout/preferences/window.ui:54 150 | msgid "Proxy" 151 | msgstr "وكيل الإتصال (Proxy)" 152 | 153 | #: resources/layout/preferences/window.ui:55 154 | msgid "Overrides system proxy settings" 155 | msgstr "إبطال إعدادات النظام للوكيل" 156 | 157 | #: resources/layout/preferences/window.ui:60 158 | msgid "Address" 159 | msgstr "العنوان" 160 | 161 | #: resources/layout/preferences/window.ui:71 162 | msgid "Port" 163 | msgstr "المنفذ" 164 | 165 | #: resources/layout/preferences/window.ui:91 166 | msgid "User agent" 167 | msgstr "عميل المستعمل (User Agent)" 168 | 169 | #: resources/layout/preferences/window.ui:92 170 | msgid "The user agent string sent in request headers" 171 | msgstr "عميل المستعمل المرسل في ترويسات الطلب" 172 | 173 | #: resources/layout/preferences/window.ui:108 174 | #: resources/layout/preferences/window.ui:121 175 | msgid "Categories" 176 | msgstr "الأصناف" 177 | 178 | #: resources/layout/preferences/window.ui:111 179 | msgid "Fallback settings" 180 | msgstr "الإعدادات الرجعية" 181 | 182 | #: resources/layout/preferences/window.ui:122 183 | msgid "" 184 | "Add and edit your categories for auto-sorting downloads. Note that changes " 185 | "only apply for new downloads." 186 | msgstr "أضف وحرر أصناف التحميلات للتنظيم التلقائي للملفات" 187 | 188 | #: resources/layout/preferences/category_row.ui:4 189 | msgid "Category" 190 | msgstr "صنف" 191 | 192 | #: resources/layout/preferences/category_editor.ui:7 193 | msgid "Edit category" 194 | msgstr "تحرير صنف" 195 | 196 | #: resources/layout/preferences/category_editor.ui:28 197 | msgid "Name" 198 | msgstr "الإسم" 199 | 200 | #: resources/layout/preferences/category_editor.ui:64 201 | msgid "Extensions" 202 | msgstr "الإمتدادات" 203 | 204 | #: src/components/download_edit.py:23 205 | msgid "Download" 206 | msgstr "تحميل" 207 | 208 | #: src/components/download_edit.py:24 209 | msgid "Cancel download" 210 | msgstr "إلغاء التحميل" 211 | 212 | #: src/components/download_edit.py:74 213 | msgid "Choose a directory" 214 | msgstr "إختر دليلا" 215 | 216 | #: src/components/download_edit.py:78 217 | #: src/components/preferences/category_editor.py:50 218 | msgid "Select" 219 | msgstr "إختيار" 220 | 221 | #: src/components/download_edit.py:102 222 | #: src/components/preferences/category_editor.py:69 223 | msgid "Permission error" 224 | msgstr "خطأ في الصلاحيات" 225 | 226 | #: src/components/download_edit.py:103 227 | #: src/components/preferences/category_editor.py:70 228 | msgid "{} is not writable" 229 | msgstr "لا يمكن الكتابة في الدليل {}" 230 | 231 | #: src/components/download_item.py:15 232 | msgid "Could not make request" 233 | msgstr "فشل الطلب" 234 | 235 | #: src/components/download_item.py:16 236 | msgid "Could not connect to server" 237 | msgstr "فشل الإتصال مع الخادم" 238 | 239 | #: src/components/download_item.py:17 240 | msgid "Connection timed out" 241 | msgstr "إنتهت مهلة الإتصال" 242 | 243 | #: src/components/download_item.py:18 244 | msgid "Could not resume download" 245 | msgstr "فشل استئناف التحميل" 246 | 247 | #: src/components/download_item.py:125 248 | msgid "An error occured" 249 | msgstr "حدث خطأ" 250 | 251 | #: src/components/preferences/category_editor.py:86 252 | msgid "Must not be empty" 253 | msgstr "لا يُترك فارغًا" 254 | 255 | #: src/controller.py:94 256 | msgid "Download finished" 257 | msgstr "انتهى التحميل" 258 | 259 | #: src/controller.py:153 260 | msgid "Delete with files" 261 | msgstr "حذف الملفات أيضا" 262 | 263 | #: src/controller.py:158 264 | msgid "Are you sure ?" 265 | msgstr "متأكد ؟" 266 | 267 | #: src/controller.py:159 268 | msgid "This will delete the selected download(s) from history." 269 | msgstr "ستُحذف التحميلات المختارة من السجل" 270 | 271 | #: src/download.py:330 272 | msgid "The file has been moved or deleted" 273 | msgstr "حُذف الملف أو نُقِل" 274 | 275 | #: src/download.py:333 276 | msgid "The file will be automatically opened once download is finished" 277 | msgstr "سيفتح الملف تلقائيا بعد انتهاء التحميل" 278 | 279 | #: src/main.py:123 280 | msgid "Download request headers" 281 | msgstr "ترويسات طلب التحميل" 282 | 283 | #: src/main.py:129 284 | msgid "Download link" 285 | msgstr "رابط التحميل" 286 | 287 | #: src/main.py:136 288 | msgid "Logs verbose level" 289 | msgstr "مستوى تسجيل أحداث التطبيق" 290 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n = import('i18n') 2 | 3 | i18n.gettext('flow', preset: 'glib') -------------------------------------------------------------------------------- /resources/com.github.essmehdi.flow.desktop.in.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=0.1.0 3 | Name=@appname@ 4 | Comment=A download manager for GNOME 5 | Exec=flow 6 | Icon=@appid@ 7 | Terminal=false 8 | Type=Application 9 | Keywords=Productivity;Download;Manager; 10 | Categories=Utility;GTK; 11 | StartupNotify=true -------------------------------------------------------------------------------- /resources/com.github.essmehdi.flow.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {} 7 | 8 | Download categories 9 | Directories and extensions for auto-sorting 10 | 11 | 12 | "~/Downloads" 13 | Fallback directory 14 | Where to put files that don't match categories 15 | 16 | 17 | 10 18 | Timeout 19 | Time given before a connection is dropped 20 | 21 | 22 | false 23 | Custom proxy 24 | Override system proxy settings for connections 25 | 26 | 27 | "" 28 | Custom proxy address 29 | Custom proxy address (if enabled) 30 | 31 | 32 | 3128 33 | Custom proxy port 34 | Custom proxy port (if enabled) 35 | 36 | 37 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36" 38 | Download user agent 39 | The user agent string sent in request headers 40 | 41 | 42 | false 43 | Force dark mode 44 | A quick toggle to force dark mode on the app 45 | 46 | 47 | -1 48 | Saved window width 49 | Saving last window width for future launches 50 | 51 | 52 | -1 53 | Saved window height 54 | Saving last window height for future launches 55 | 56 | 57 | false 58 | Saved window maximized state 59 | Saving last window maximized state 60 | 61 | 62 | -------------------------------------------------------------------------------- /resources/flow.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | layout/main.ui 5 | layout/url_prompt.ui 6 | layout/download_item.ui 7 | layout/download_edit.ui 8 | layout/about.ui 9 | layout/browser_wait.ui 10 | layout/file_chooser_button.ui 11 | layout/shortcuts.ui 12 | layout/preferences/window.ui 13 | layout/preferences/category_row.ui 14 | layout/preferences/add_row.ui 15 | layout/preferences/category_editor.ui 16 | style.css 17 | 18 | -------------------------------------------------------------------------------- /resources/icons/com.github.essmehdi.flow.devel.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 36 | 40 | 44 | 46 | 55 | 59 | 64 | 68 | 72 | 77 | 82 | 83 | 90 | 93 | 98 | 99 | 106 | 109 | 114 | 115 | 122 | 125 | 130 | 131 | 140 | 142 | 146 | 150 | 151 | 160 | 161 | 165 | 168 | 169 | 172 | 174 | 179 | 182 | 183 | 184 | 188 | 192 | 196 | 200 | 204 | 205 | -------------------------------------------------------------------------------- /resources/icons/com.github.essmehdi.flow.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 35 | 39 | 43 | 47 | 51 | 55 | 57 | 66 | 70 | 75 | 79 | 83 | 88 | 93 | 94 | 101 | 104 | 109 | 110 | 117 | 120 | 125 | 126 | 133 | 136 | 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /resources/icons/meson.build: -------------------------------------------------------------------------------- 1 | install_data( 2 | app_id + '.svg', 3 | install_dir: join_paths(get_option('datadir'), 'icons/hicolor/scalable/apps') 4 | ) -------------------------------------------------------------------------------- /resources/layout/about.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | -------------------------------------------------------------------------------- /resources/layout/browser_wait.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 112 | -------------------------------------------------------------------------------- /resources/layout/download_edit.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 144 | -------------------------------------------------------------------------------- /resources/layout/download_item.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 195 | 196 |
197 | 198 | Edit 199 | download.edit 200 | 201 |
202 |
203 | 204 | Resume with new link (Experimental) 205 | download.resume-with-link 206 | 207 | 208 | Copy download link 209 | download.copy-url 210 | edit-copy-symbolic 211 | 212 |
213 |
214 | 215 | Open file 216 | download.open-file 217 | document-open-symbolic 218 | 219 | 220 | Open containing folder 221 | download.open-folder 222 | folder-open-symbolic 223 | 224 |
225 |
226 | 227 | Delete 228 | download.delete 229 | user-trash-symbolic 230 | 231 | 232 | Delete with file 233 | download.delete-with-file 234 | user-trash-symbolic 235 | 236 |
237 |
238 |
-------------------------------------------------------------------------------- /resources/layout/empty.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | -------------------------------------------------------------------------------- /resources/layout/file_chooser_button.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | -------------------------------------------------------------------------------- /resources/layout/main.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 191 | 192 | 193 | Preferences 194 | win.preferences 195 | 196 | 197 | Keyboard shortcuts 198 | win.shortcuts 199 | 200 | 201 | About 202 | win.about 203 | 204 | 205 | 206 | menu 207 | 208 | 209 | -------------------------------------------------------------------------------- /resources/layout/preferences/add_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | list-add-symbolic 7 | fill 8 | 1 9 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /resources/layout/preferences/category_editor.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 107 | -------------------------------------------------------------------------------- /resources/layout/preferences/category_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | -------------------------------------------------------------------------------- /resources/layout/preferences/window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 135 | 136 | -------------------------------------------------------------------------------- /resources/layout/shortcuts.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 40 | -------------------------------------------------------------------------------- /resources/layout/url_prompt.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 66 | 67 | -------------------------------------------------------------------------------- /resources/meson.build: -------------------------------------------------------------------------------- 1 | gnome = import('gnome') 2 | i18n = import('i18n') 3 | 4 | gnome.compile_resources( 5 | 'flow', 6 | 'flow.gresource.xml', 7 | gresource_bundle: true, 8 | install: true, 9 | install_dir: pkgdatadir, 10 | ) 11 | 12 | subdir('icons') 13 | 14 | install_data('com.github.essmehdi.flow.gschema.xml', 15 | install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas') 16 | ) 17 | 18 | # Setup & verify desktop entry 19 | desktop_file = i18n.merge_file( 20 | input: configure_file( 21 | input: 'com.github.essmehdi.flow.desktop.in.in', 22 | output: 'com.github.essmehdi.flow.desktop.in', 23 | configuration: { 24 | 'appname': app_name, 25 | 'appid': app_id, 26 | } 27 | ), 28 | type: 'desktop', 29 | po_dir: '../po', 30 | output: '@0@.desktop'.format(app_id), 31 | install: true, 32 | install_dir: join_paths(get_option('datadir'), 'applications') 33 | ) 34 | 35 | desktop_validate = find_program('desktop-file-validate', required: false) 36 | if desktop_validate.found() 37 | test('Validate desktop file', desktop_validate, 38 | args: [desktop_file] 39 | ) 40 | endif 41 | 42 | compile_schemas = find_program('glib-compile-schemas', required: false) 43 | if compile_schemas.found() 44 | test('Validate schema file', compile_schemas, 45 | args: ['--strict', '--dry-run', meson.current_source_dir()] 46 | ) 47 | endif -------------------------------------------------------------------------------- /resources/style.css: -------------------------------------------------------------------------------- 1 | overshoot { 2 | background-image: none; 3 | } -------------------------------------------------------------------------------- /screenshots/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essmehdi/flow/2b70a567f74d7d00fce09f9c32e7595e1f01227e/screenshots/main.png --------------------------------------------------------------------------------