#!/usr/bin/env python3 # Copyright 2016-2020 Jay Flood, SP, Brasil # All rights reserved. # # Redistribution and use of this script, with or without modification, is # permitted provided that the following conditions are met: # # 1. Redistributions of this script must retain the above copyright # notice, this list of conditions and the following disclaimer. # # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ''AS IS'' AND ANY EXPRESS OR IMPLIED # WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO # EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # # Author: brokenman@porteus.org # # Python port of Porteus bundles downloader (GUI + console modes). # Run with --console for terminal mode, otherwise GTK3 GUI is used. import os, sys, re, hashlib, shutil, tempfile, subprocess import urllib.request, threading from pathlib import Path # ---------------------------------------------------------------------- # Internationalisation (i18n) – easily extensible # ---------------------------------------------------------------------- # Translation dictionaries. Add new languages as new entries. # Keys are the original English strings; values are the translated strings. TRANSLATIONS = { 'ru': { 'Porteus bundles downloader': 'Загрузчик бандлов Porteus', 'Module': 'Бандл', 'Cancel': 'Отмена', 'OK': 'ОК', 'Could not download the bundle list from:\n{url}': 'Не удалось загрузить список бандлов с:\n{url}', 'Bundle list does not contain any .xzm modules at:\n{url}': 'Список бандлов не содержит .xzm модулей по адресу:\n{url}', 'No bundles found for current architecture and desktop.': 'Для текущей архитектуры и рабочего стола бандлов не найдено.', 'You must select a bundle to download.': 'Необходимо выбрать бандл для загрузки.', 'Download failed': 'Ошибка загрузки', 'Checksum match. Module verified.': 'Контрольная сумма совпадает. Бандл проверен.', 'Checksum mismatch! Possible corrupt module.': 'Несовпадение контрольной суммы! Возможно бандл повреждён.', 'Module activation': 'Активация бандла', 'Would you like to activate the module now?': 'Активировать бандл сейчас?', 'Module activated': 'Бандл активирован', 'has been activated.': 'активирован.', 'Activation failed': 'Ошибка активации', 'This script can only run on Porteus.': 'Этот скрипт может работать только в Porteus.', 'Only root can run this script. (console mode)': 'Этот скрипт может запускать только root. (консольный режим)', 'Welcome to the Porteus bundle manager': 'Добро пожаловать в менеджер бандлов Porteus', 'Updating list of packages ...': 'Обновление списка пакетов ...', 'Available bundles:': 'Доступные бандлы:', 'Enter number of the bundle to download (0 to quit):': 'Введите номер бандла для загрузки (0 для выхода):', 'Invalid selection.': 'Неверный выбор.', 'Downloading': 'Загрузка', 'Verifying download integrity...': 'Проверка целостности загрузки...', 'Success. File saved to:': 'Готово. Файл сохранён в:', 'Target directory does not exist or is not writable.': 'Целевая папка не существует или недоступна для записи.', 'Enter download path:': 'Укажите путь для сохранения:', 'Checksum verification skipped:': 'Проверка контрольной суммы пропущена:', ' Porteus bundles downloader': ' Загрузчик бандлов Porteus', }, # Add other languages here, e.g.: # 'de': { ... }, } def _(text, **kwargs): """ Return translated string if available for the current locale, otherwise return the original English text. Supports Python's str.format() placeholders. """ lang = os.environ.get('LANG', '') # Use only the first two characters to match the language code lang_code = lang[:2] if len(lang) >= 2 else 'en' # Get translation dictionary for the language, fallback to English lang_dict = TRANSLATIONS.get(lang_code, {}) translated = lang_dict.get(text, text) # Apply any formatting arguments if kwargs: return translated.format(**kwargs) return translated # ---------------------------------------------------------------------- # Porteus helpers (no bash needed) # ---------------------------------------------------------------------- def is_porteus(): return os.path.isfile('/etc/porteus-version') def is_writable(path): return os.access(path, os.W_OK) def get_desktop(): """Get current desktop name in lowercase.""" try: res = subprocess.run(['bash', '-c', '. /usr/share/porteus/porteus-functions; get_desktop'], capture_output=True, text=True, timeout=5) if res.returncode == 0 and res.stdout.strip(): return res.stdout.strip().lower() except: pass desktop = os.environ.get('XDG_CURRENT_DESKTOP', '').lower() if not desktop: try: with open('/etc/porteus/desktop') as f: desktop = f.read().strip().lower() except: pass return desktop def get_server(): try: with open('/etc/porteus.conf') as f: for line in f: if line.startswith('SERVER='): return line.split('=', 1)[1].strip() except: pass return 'http://dl.porteus.org' # ---------------------------------------------------------------------- # Core download logic (shared between GUI and console) # ---------------------------------------------------------------------- def fetch_bundle_list(server, arch, pver, desktop, tmpdir): """Download index.html and return list of .xzm names matching arch & desktop.""" url = f"{server}/{arch}/{pver}/bundles/" idx_path = os.path.join(tmpdir, 'index.html') try: urllib.request.urlretrieve(url, idx_path) except Exception as e: raise RuntimeError( _('Could not download the bundle list from:\n{url}', url=url) + f'\n{e}' ) if not os.path.isfile(idx_path): raise RuntimeError( _('Could not download the bundle list from:\n{url}', url=url) ) with open(idx_path, 'r', errors='ignore') as f: content = f.read() if '.xzm' not in content: raise RuntimeError( _('Bundle list does not contain any .xzm modules at:\n{url}', url=url) ) # Extract href links, filter by arch and desktop links = re.findall(r' 0: progress_callback(downloaded / total) if total > 0 and downloaded < total: raise Exception("Download incomplete") except: if os.path.isfile(destination): os.unlink(destination) raise def md5_verify(filepath, module_name, server, arch, pver): """Compare md5sum with server's md5sums.txt. Returns True if match, raises on error.""" md5_url = f"{server}/{arch}/{pver}/bundles/md5sums.txt" try: with urllib.request.urlopen(md5_url) as resp: data = resp.read().decode('utf-8', errors='ignore') expected = None for line in data.splitlines(): if module_name in line: expected = line.split()[0] break if not expected: raise ValueError("md5sum not found for this module") except Exception as e: raise RuntimeError(f"{_('Checksum verification skipped:')} {e}") h = hashlib.md5() with open(filepath, 'rb') as f: while chunk := f.read(8192): h.update(chunk) if h.hexdigest() != expected: raise ValueError(_('Checksum mismatch! Possible corrupt module.')) return True # ---------------------------------------------------------------------- # Console mode # ---------------------------------------------------------------------- def console_mode(output_dir=None): """Interactive terminal version.""" if os.geteuid() != 0: print(_('Only root can run this script. (console mode)')) sys.exit(1) server = get_server() arch = 'x86_64' if os.uname().machine == 'x86_64' else 'i586' with open('/etc/porteus-version') as f: pver = f.read().strip() desktop = get_desktop() tmpdir = tempfile.mkdtemp(prefix='.bundle.') print() print(_('Welcome to the Porteus bundle manager')) print(_('Updating list of packages ...')) try: modules = fetch_bundle_list(server, arch, pver, desktop, tmpdir) except RuntimeError as e: print(e) shutil.rmtree(tmpdir, ignore_errors=True) sys.exit(1) print(_('Available bundles:')) for i, mod in enumerate(modules, 1): print(f"{i:3d}. {mod}") # User selection while True: try: choice = input(_('Enter number of the bundle to download (0 to quit): ')) choice = int(choice) if choice == 0: shutil.rmtree(tmpdir, ignore_errors=True) return if 1 <= choice <= len(modules): selected = modules[choice - 1] break except (ValueError, IndexError): pass print(_('Invalid selection.')) # Target directory if output_dir: target_dir = output_dir else: target_dir = input(_('Enter download path: ') + ' ').strip() if not target_dir or not os.path.isdir(target_dir) or not os.access(target_dir, os.W_OK): print(_('Target directory does not exist or is not writable.')) shutil.rmtree(tmpdir, ignore_errors=True) sys.exit(1) # Download url = f"{server}/{arch}/{pver}/bundles/{selected}" dest = os.path.join(target_dir, selected) print(f"\n{_('Downloading')} {selected} ...") last_pct = 0 def progress(p): nonlocal last_pct pct = int(p * 100) if pct != last_pct: print(f"\rProgress: {pct:3d}%", end='', flush=True) last_pct = pct try: download_file(url, dest, progress_callback=progress) print() except Exception as e: print(f"\n{_('Download failed')}: {e}") shutil.rmtree(tmpdir, ignore_errors=True) sys.exit(1) # Verify print(_('Verifying download integrity...')) try: md5_verify(dest, selected, server, arch, pver) print(_('Checksum match. Module verified.')) except (ValueError, RuntimeError) as e: print(e) shutil.rmtree(tmpdir, ignore_errors=True) sys.exit(1) print(_('Success. File saved to:'), dest) shutil.rmtree(tmpdir, ignore_errors=True) # ---------------------------------------------------------------------- # GUI mode # ---------------------------------------------------------------------- def gui_mode(): import gi gi.require_version('Gtk', '3.0') gi.require_version('GLib', '2.0') from gi.repository import Gtk, GLib, GdkPixbuf if not is_porteus(): print("This script can only run on Porteus.") sys.exit(1) server = get_server() arch = 'x86_64' if os.uname().machine == 'x86_64' else 'i586' with open('/etc/porteus-version') as f: pver = f.read().strip() desktop = get_desktop() tmpdir = tempfile.mkdtemp(prefix='.bundle.') # Determine default download directory moddir = os.environ.get('MODDIR', '') target_dir = moddir if (moddir and is_writable(moddir)) else '/tmp' class BundleWindow(Gtk.Window): def __init__(self): super().__init__(title=_('Porteus bundles downloader')) self.set_border_width(10) self.set_position(Gtk.WindowPosition.CENTER) try: self.set_icon_name('cdr') except: pass self.modules = [] self.selected_module = None self.store = Gtk.ListStore(str) self.build_ui() threading.Thread(target=self.load_list, daemon=True).start() def build_ui(self): vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.add(vbox) # Header hbox = Gtk.Box(spacing=6) try: pix = GdkPixbuf.Pixbuf.new_from_icon_name('cdr', Gtk.IconSize.DIALOG) img = Gtk.Image.new_from_pixbuf(pix) except: img = Gtk.Image.new_from_icon_name('cdr', Gtk.IconSize.DIALOG) hbox.pack_start(img, False, False, 0) lbl = Gtk.Label() lbl.set_markup('' + _(' Porteus bundles downloader') + '') lbl.set_halign(Gtk.Align.START) hbox.pack_start(lbl, True, True, 0) vbox.pack_start(hbox, False, False, 0) vbox.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0) # Scrolled list frame = Gtk.Frame() scroll = Gtk.ScrolledWindow() scroll.set_min_content_width(400) scroll.set_min_content_height(300) self.tree = Gtk.TreeView(model=self.store) renderer = Gtk.CellRendererText() col = Gtk.TreeViewColumn(_('Module'), renderer, text=0) self.tree.append_column(col) self.tree.set_headers_visible(False) scroll.add(self.tree) frame.add(scroll) vbox.pack_start(frame, True, True, 0) # Buttons btn_box = Gtk.Box(spacing=8) btn_box.set_halign(Gtk.Align.END) btn_cancel = Gtk.Button(label=_('Cancel')) btn_cancel.connect('clicked', self.on_cancel) btn_ok = Gtk.Button(label=_('OK')) btn_ok.connect('clicked', self.on_ok) btn_box.pack_start(btn_cancel, False, False, 0) btn_box.pack_start(btn_ok, False, False, 0) vbox.pack_start(btn_box, False, False, 0) self.show_all() def load_list(self): try: self.modules = fetch_bundle_list(server, arch, pver, desktop, tmpdir) except RuntimeError as e: GLib.idle_add(self.show_error, str(e)) return GLib.idle_add(self.populate) def populate(self): for m in self.modules: self.store.append([m]) return False def show_error(self, msg): dlg = Gtk.MessageDialog(transient_for=self, flags=0, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.CLOSE, text=msg) dlg.run() dlg.destroy() self.quit() def on_cancel(self, w): self.quit() def on_ok(self, w): sel = self.tree.get_selection() model, it = sel.get_selected() if it is None: self.show_error(_('You must select a bundle to download.')) return self.selected_module = model[it][0] if not self.selected_module.endswith('.xzm'): self.show_error(_('You must select a bundle to download.')) return self.hide() threading.Thread(target=self.download_and_verify, daemon=True).start() def download_and_verify(self): url = f"{server}/{arch}/{pver}/bundles/{self.selected_module}" dest = os.path.join(target_dir, self.selected_module) # Progress dialog dlg_progress = Gtk.Dialog(title=f'Downloading {self.selected_module}', transient_for=self, modal=True) dlg_progress.set_default_size(300, 100) cancel_flag = [False] def do_cancel(b): cancel_flag[0] = True btn_cancel = Gtk.Button(label=_('Cancel')) btn_cancel.connect('clicked', do_cancel) lbl = Gtk.Label(label=f'Saving to: {self.selected_module}') bar = Gtk.ProgressBar() box = dlg_progress.get_content_area() box.pack_start(lbl, False, False, 4) box.pack_start(bar, True, True, 4) box.pack_start(btn_cancel, False, False, 4) dlg_progress.show_all() def update_progress(f): GLib.idle_add(bar.set_fraction, f) try: download_file(url, dest, progress_callback=update_progress, cancel_check=lambda: cancel_flag[0]) except Exception as e: GLib.idle_add(dlg_progress.destroy) GLib.idle_add(self.show_error, f"{_('Download failed')}\n{e}") return GLib.idle_add(dlg_progress.destroy) # Verify try: md5_verify(dest, self.selected_module, server, arch, pver) except (ValueError, RuntimeError) as e: GLib.idle_add(self.show_error, str(e)) return # Success – ask to activate GLib.idle_add(self.ask_activate, dest) def ask_activate(self, module_path): dlg = Gtk.MessageDialog(transient_for=self, flags=0, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO, text=_('Module activation')) dlg.format_secondary_text( _('Would you like to activate the module now?') + f'\n({module_path})') resp = dlg.run() dlg.destroy() if resp == Gtk.ResponseType.YES: try: subprocess.run(['activate', module_path], check=True) self.show_info(f"{_('Module activated')}: {os.path.basename(module_path)} {_('has been activated.')}") except subprocess.CalledProcessError as e: self.show_error(f"{_('Activation failed')}: {e}") else: self.quit() def show_info(self, msg): dlg = Gtk.MessageDialog(transient_for=self, flags=0, message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.CLOSE, text=msg) dlg.run() dlg.destroy() self.quit() def quit(self): shutil.rmtree(tmpdir, ignore_errors=True) Gtk.main_quit() app = BundleWindow() Gtk.main() # ---------------------------------------------------------------------- # Entry point # ---------------------------------------------------------------------- if __name__ == '__main__': if '--console' in sys.argv: # Optional --output-dir PATH argument out_dir = None try: idx = sys.argv.index('--output-dir') out_dir = sys.argv[idx + 1] except (ValueError, IndexError): pass console_mode(out_dir) else: gui_mode()