You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

845 lines
30 KiB

#!/usr/bin/env python3
"""Cellar GTK – client grafico per Bottle Archive Server (libadwaita)."""
from __future__ import annotations
import json
import os
import sys
import threading
import urllib.error
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Callable
# When running as a frozen binary (PyInstaller) on non-GNOME desktops (e.g. KDE),
# no GTK theme is configured by default. Force Adwaita so the bundled theme is
# used and the UI renders correctly.
if getattr(sys, "frozen", False):
os.environ.setdefault("GTK_THEME", "Adwaita")
# Allow GTK4 to fall back from Wayland to X11 if the Wayland compositor
# does not support the required protocols (common on some KDE setups).
os.environ.setdefault("GDK_BACKEND", "wayland,x11")
try:
import gi # type: ignore[import-not-found]
except ImportError as exc:
raise SystemExit(
"PyGObject non trovato. Installa 'python3-gobject', 'gtk4' e 'libadwaita'."
) from exc
from client import (
CONF_FILE,
DEFAULT_BOTTLES_DIR,
DEFAULT_SERVER,
collect_local_bottles,
get_archives,
install_archive_from_metadata,
load_cellar_conf,
)
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, GLib, Gtk # type: ignore[import-not-found]
# ── Constants ───────────────────────────────────────────────────
APP_ID = "net.enne2.Cellar"
CONFIG_PATH = Path.home() / ".config" / "cellar" / "gtk_client.json"
APP_CSS = """\
.archive-row {
padding: 14px 16px;
}
.archive-title {
font-weight: bold;
font-size: 1.05em;
}
.archive-meta {
font-size: 0.88em;
}
.status-chip {
font-size: 0.82em;
font-weight: 600;
padding: 2px 10px;
border-radius: 99px;
}
.status-chip.installed {
color: @success_color;
background: alpha(@success_color, 0.12);
}
.status-chip.not-installed {
color: alpha(@window_fg_color, 0.45);
background: alpha(@window_fg_color, 0.06);
}
progressbar trough {
min-height: 4px;
}
progressbar progress {
min-height: 4px;
border-radius: 2px;
}
"""
# ── Config ──────────────────────────────────────────────────────
@dataclass
class AppConfig:
server_url: str = DEFAULT_SERVER
bottles_dir: str = str(DEFAULT_BOTTLES_DIR)
replace_existing: bool = False
def load_config() -> AppConfig:
conf = load_cellar_conf() # reads/creates ~/.cellar.conf
base_server = conf["server"]
base_bottles = conf["bottles_dir"]
if not CONFIG_PATH.exists():
return AppConfig(server_url=base_server, bottles_dir=base_bottles)
try:
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return AppConfig(server_url=base_server, bottles_dir=base_bottles)
return AppConfig(
server_url=str(raw.get("server_url") or base_server).rstrip("/"),
bottles_dir=str(raw.get("bottles_dir") or base_bottles),
replace_existing=bool(raw.get("replace_existing", False)),
)
def save_config(config: AppConfig) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")
# ── Utility ─────────────────────────────────────────────────────
def format_size(size_bytes: int | None) -> str:
if not size_bytes:
return ""
size = float(size_bytes)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.1f} {unit}"
size /= 1024
return f"{size_bytes} B"
def archive_matches_query(archive: dict[str, Any], query: str) -> bool:
normalized = query.strip().lower()
if not normalized:
return True
searchable = (
archive.get("name"),
archive.get("bottle_name"),
archive.get("description"),
archive.get("tags"),
archive.get("runner"),
archive.get("arch"),
archive.get("windows_version"),
)
haystack = " ".join(str(v or "") for v in searchable).lower()
return normalized in haystack
def normalize_name(value: str | None) -> str:
return (value or "").strip().lower()
def _load_css() -> None:
provider = Gtk.CssProvider()
if hasattr(provider, "load_from_string"):
provider.load_from_string(APP_CSS)
else:
provider.load_from_data(APP_CSS.encode("utf-8"))
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(),
provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
class CompatBanner(Gtk.Revealer):
"""Fallback banner for libadwaita versions that don't provide `Adw.Banner`."""
def __init__(self) -> None:
super().__init__()
self.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
box.set_margin_top(8)
box.set_margin_bottom(8)
box.set_margin_start(12)
box.set_margin_end(12)
box.add_css_class("toolbar")
self._label = Gtk.Label(xalign=0)
self._label.set_wrap(True)
self._label.set_hexpand(True)
box.append(self._label)
self.set_child(box)
def set_title(self, title: str) -> None:
self._label.set_text(title)
def set_revealed(self, revealed: bool) -> None:
self.set_reveal_child(revealed)
class CompatToolbarView(Gtk.Box):
"""Fallback for `Adw.ToolbarView` (available since libadwaita 1.4)."""
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self._content: Gtk.Widget | None = None
def add_top_bar(self, widget: Gtk.Widget) -> None:
self.append(widget)
def set_content(self, widget: Gtk.Widget) -> None:
if self._content is not None:
self.remove(self._content)
self._content = widget
widget.set_vexpand(True)
self.append(widget)
class CompatEntryRow(Adw.ActionRow):
"""Fallback for `Adw.EntryRow` (available since libadwaita 1.2)."""
def __init__(self, title: str = ""):
super().__init__(title=title)
self._entry = Gtk.Entry()
self._entry.set_hexpand(True)
self.add_suffix(self._entry)
self.set_activatable_widget(self._entry)
def get_text(self) -> str:
return self._entry.get_text()
def set_text(self, text: str) -> None:
self._entry.set_text(text)
class CompatSwitchRow(Adw.ActionRow):
"""Fallback for `Adw.SwitchRow` (available since libadwaita 1.4)."""
def __init__(self, title: str = ""):
super().__init__(title=title)
self._switch = Gtk.Switch()
self._switch.set_valign(Gtk.Align.CENTER)
self.add_suffix(self._switch)
self.set_activatable_widget(self._switch)
def get_active(self) -> bool:
return self._switch.get_active()
def set_active(self, active: bool) -> None:
self._switch.set_active(active)
def connect_active_notify(self, callback: Callable[..., None]) -> None:
self._switch.connect("notify::active", callback)
def make_toolbar_view() -> Gtk.Widget:
if hasattr(Adw, "ToolbarView"):
return Adw.ToolbarView()
return CompatToolbarView()
def make_entry_row(title: str) -> Gtk.Widget:
if hasattr(Adw, "EntryRow"):
return Adw.EntryRow(title=title)
return CompatEntryRow(title=title)
def make_switch_row(title: str) -> Gtk.Widget:
if hasattr(Adw, "SwitchRow"):
return Adw.SwitchRow(title=title)
return CompatSwitchRow(title=title)
def connect_switch_row_active(row: Gtk.Widget, callback: Callable[..., None]) -> None:
if hasattr(row, "connect_active_notify"):
row.connect_active_notify(callback) # type: ignore[attr-defined]
else:
row.connect("notify::active", callback)
# ── Settings Dialog ─────────────────────────────────────────────
class SettingsWindow(Adw.Window):
"""Preferences dialog using libadwaita widgets."""
def __init__(
self,
parent: "CellarWindow",
config: AppConfig,
on_save: Callable[[AppConfig], None],
):
super().__init__(title="Configurazione")
self._parent_win = parent
self._on_save = on_save
self.set_transient_for(parent)
self.set_modal(True)
self.set_default_size(480, -1)
toolbar_view = make_toolbar_view()
header = Adw.HeaderBar()
cancel_btn = Gtk.Button(label="Annulla")
cancel_btn.connect("clicked", lambda *_: self.close())
save_btn = Gtk.Button(label="Salva")
save_btn.add_css_class("suggested-action")
save_btn.connect("clicked", self._save)
header.pack_start(cancel_btn)
header.pack_end(save_btn)
toolbar_view.add_top_bar(header)
page = Adw.PreferencesPage()
group = Adw.PreferencesGroup()
group.set_title("Connessione")
group.set_description(
"Configura l'indirizzo del server Cellar e la cartella "
"locale di Bottles usata per l'installazione."
)
self.server_row = make_entry_row("Indirizzo server")
self.server_row.set_text(config.server_url)
group.add(self.server_row)
self.bottles_row = make_entry_row("Cartella Bottles")
self.bottles_row.set_text(config.bottles_dir)
group.add(self.bottles_row)
page.add(group)
toolbar_view.set_content(page)
self.set_content(toolbar_view)
def _save(self, *_args: object) -> None:
server_url = self.server_row.get_text().strip().rstrip("/")
bottles_dir = self.bottles_row.get_text().strip()
if not server_url:
return
config = AppConfig(
server_url=server_url,
bottles_dir=(
str(Path(bottles_dir).expanduser()) if bottles_dir else str(DEFAULT_BOTTLES_DIR)
),
replace_existing=self._parent_win.replace_row.get_active(),
)
save_config(config)
self._on_save(config)
self.close()
# ── Archive Row ─────────────────────────────────────────────────
class ArchiveRow(Gtk.ListBoxRow):
"""A single archive entry in the list, with status icon, info and action button."""
def __init__(
self,
archive: dict[str, Any],
on_install: Callable[[dict[str, Any]], None],
is_installed: bool,
replace_enabled: bool,
):
super().__init__()
self.archive = archive
self.set_selectable(False)
self.set_activatable(False)
root = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
root.add_css_class("archive-row")
# ── Status icon ──
icon = Gtk.Image()
icon.set_pixel_size(32)
icon.set_valign(Gtk.Align.CENTER)
if is_installed:
icon.set_from_icon_name("object-select-symbolic")
icon.add_css_class("success")
else:
icon.set_from_icon_name("folder-download-symbolic")
icon.add_css_class("dim-label")
# ── Text column ──
text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
text_box.set_hexpand(True)
text_box.set_valign(Gtk.Align.CENTER)
title = archive.get("name") or archive.get("bottle_name") or "Archivio senza nome"
title_label = Gtk.Label(xalign=0)
title_label.set_text(title)
title_label.add_css_class("archive-title")
# Info line: bottle · runner · arch · windows · size
bottle_name = archive.get("bottle_name") or ""
runner = archive.get("runner") or ""
arch = archive.get("arch") or ""
win_ver = archive.get("windows_version") or ""
size = format_size(archive.get("size_bytes"))
info_label = Gtk.Label(
xalign=0,
wrap=True,
label=f"{bottle_name} · {runner} · {arch} · {win_ver} · {size}",
)
info_label.add_css_class("dim-label")
info_label.add_css_class("archive-meta")
# Meta line: tags · date · description
tags = archive.get("tags") or ""
created = (archive.get("created_at") or "")[:10]
description = archive.get("description") or ""
meta_parts = [p for p in (tags, created, description) if p]
text_box.append(title_label)
text_box.append(info_label)
if meta_parts:
meta_label = Gtk.Label(
xalign=0, wrap=True, label=" · ".join(meta_parts)
)
meta_label.add_css_class("dim-label")
meta_label.add_css_class("archive-meta")
text_box.append(meta_label)
# Status chip
status_label = Gtk.Label(xalign=0)
status_label.add_css_class("status-chip")
if is_installed:
status_label.set_label("✓ Installata")
status_label.add_css_class("installed")
else:
status_label.set_label("Non installata")
status_label.add_css_class("not-installed")
chip_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
chip_box.set_margin_top(2)
chip_box.append(status_label)
text_box.append(chip_box)
# ── Install button ──
if is_installed and replace_enabled:
btn_label = "Reinstalla"
elif is_installed:
btn_label = "Già installata"
else:
btn_label = "Installa"
self.install_button = Gtk.Button(label=btn_label)
self.install_button.set_valign(Gtk.Align.CENTER)
self.install_button.add_css_class("pill")
if not is_installed or replace_enabled:
self.install_button.add_css_class("suggested-action")
else:
self.install_button.add_css_class("flat")
self.install_button.connect("clicked", lambda *_: on_install(self.archive))
self.install_button.set_sensitive((not is_installed) or replace_enabled)
root.append(icon)
root.append(text_box)
root.append(self.install_button)
self.set_child(root)
def set_sensitive_state(self, enabled: bool) -> None:
self.install_button.set_sensitive(enabled)
# ── Main Window ─────────────────────────────────────────────────
class CellarWindow(Adw.ApplicationWindow):
"""Primary application window using Adw layout widgets."""
def __init__(self, app: Adw.Application):
super().__init__(application=app, title="Cellar")
self.set_icon_name(APP_ID)
self.config = load_config()
self.settings_window: SettingsWindow | None = None
self.rows: list[ArchiveRow] = []
self.all_archives: list[dict[str, Any]] = []
self.local_bottle_names: set[str] = set()
self.busy = False
self.set_default_size(800, 680)
# ── Header bar ──
header = Adw.HeaderBar()
self._window_title = Adw.WindowTitle(title="Cellar", subtitle="Bottle Archive")
header.set_title_widget(self._window_title)
self.refresh_button = Gtk.Button(
icon_name="view-refresh-symbolic",
tooltip_text="Aggiorna catalogo",
)
self.refresh_button.connect("clicked", lambda *_: self.refresh_archives())
header.pack_start(self.refresh_button)
self.spinner = Gtk.Spinner()
header.pack_start(self.spinner)
self.settings_button = Gtk.Button(
icon_name="emblem-system-symbolic",
tooltip_text="Configurazione",
)
self.settings_button.connect("clicked", lambda *_: self.open_settings())
header.pack_end(self.settings_button)
# ── Toast overlay ──
self.toast_overlay = Adw.ToastOverlay()
# ── Outer box (banner + progress + scrolled content) ──
outer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
if hasattr(Adw, "Banner"):
self.banner = Adw.Banner()
else:
self.banner = CompatBanner()
self.banner.set_revealed(False)
outer_box.append(self.banner)
self.progress_bar = Gtk.ProgressBar()
self.progress_bar.set_visible(False)
outer_box.append(self.progress_bar)
# ── Scrolled area ──
scroller = Gtk.ScrolledWindow()
scroller.set_vexpand(True)
clamp = Adw.Clamp()
clamp.set_maximum_size(720)
clamp.set_tightening_threshold(500)
content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
content_box.set_margin_top(18)
content_box.set_margin_bottom(24)
content_box.set_margin_start(12)
content_box.set_margin_end(12)
# ── Info group (PreferencesGroup) ──
info_group = Adw.PreferencesGroup()
info_group.set_title("Connessione")
self.server_info_row = Adw.ActionRow(title="Server")
self.server_info_row.set_subtitle(self.config.server_url)
self.server_info_row.add_css_class("property")
info_group.add(self.server_info_row)
self.bottles_info_row = Adw.ActionRow(title="Cartella locale")
self.bottles_info_row.set_subtitle(self.config.bottles_dir)
self.bottles_info_row.add_css_class("property")
info_group.add(self.bottles_info_row)
self.replace_row = make_switch_row("Sostituisci installazioni esistenti")
self.replace_row.set_active(self.config.replace_existing)
connect_switch_row_active(self.replace_row, self._store_replace_preference)
info_group.add(self.replace_row)
content_box.append(info_group)
# ── Search entry ──
self.search_entry = Gtk.SearchEntry()
self.search_entry.set_placeholder_text(
"Cerca per nome, bottle, tag, runner, descrizione…"
)
self.search_entry.connect("search-changed", self._on_search_changed)
content_box.append(self.search_entry)
# ── Summary ──
self.summary_label = Gtk.Label(xalign=0)
self.summary_label.add_css_class("dim-label")
self.summary_label.add_css_class("caption")
content_box.append(self.summary_label)
# ── Archive list / empty state ──
self.status_page = Adw.StatusPage()
self.status_page.set_icon_name("system-software-install-symbolic")
self.status_page.set_title("Nessun archivio")
self.status_page.set_description(
"Premi il pulsante ↻ per caricare il catalogo dal server."
)
self.listbox = Gtk.ListBox()
self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
self.listbox.add_css_class("boxed-list")
self.content_stack = Gtk.Stack()
self.content_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
self.content_stack.set_transition_duration(200)
self.content_stack.add_named(self.status_page, "placeholder")
self.content_stack.add_named(self.listbox, "list")
content_box.append(self.content_stack)
clamp.set_child(content_box)
scroller.set_child(clamp)
outer_box.append(scroller)
self.toast_overlay.set_child(outer_box)
# ── ToolbarView ──
toolbar_view = make_toolbar_view()
toolbar_view.add_top_bar(header)
toolbar_view.set_content(self.toast_overlay)
self.set_content(toolbar_view)
# ── Init state ──
self.refresh_local_bottles()
self.update_summary(0, 0)
self.refresh_archives()
# ── Preferences ─────────────────────────────────────────────
def _store_replace_preference(self, *_args: object) -> None:
self.config.replace_existing = self.replace_row.get_active()
save_config(self.config)
self.apply_archive_filter()
def update_info_labels(self) -> None:
self.server_info_row.set_subtitle(self.config.server_url)
self.bottles_info_row.set_subtitle(self.config.bottles_dir)
self.replace_row.set_active(self.config.replace_existing)
def update_summary(self, visible_count: int, total_count: int) -> None:
if total_count == 0:
self.summary_label.set_text("Nessun archivio caricato.")
elif visible_count == total_count:
self.summary_label.set_text(f"Archivi visibili: {visible_count}")
else:
self.summary_label.set_text(
f"Archivi visibili: {visible_count} su {total_count}"
)
# ── Settings window ─────────────────────────────────────────
def open_settings(self) -> None:
if self.settings_window is not None:
self.settings_window.present()
return
self.settings_window = SettingsWindow(self, self.config, self.apply_config)
self.settings_window.connect("close-request", self._on_settings_closed)
self.settings_window.present()
def _on_settings_closed(self, window: Gtk.Window) -> bool:
if window is self.settings_window:
self.settings_window = None
return False
def apply_config(self, config: AppConfig) -> None:
self.config = config
self.update_info_labels()
self.refresh_local_bottles()
self.refresh_archives()
# ── Local bottles ───────────────────────────────────────────
def refresh_local_bottles(self) -> None:
bottles_dir = Path(self.config.bottles_dir).expanduser()
local_bottles = collect_local_bottles(bottles_dir)
known: set[str] = set()
for b in local_bottles:
known.add(normalize_name(b.get("directory")))
known.add(normalize_name(b.get("name")))
self.local_bottle_names = {n for n in known if n}
def is_archive_installed(self, archive: dict[str, Any]) -> bool:
candidates = {
normalize_name(archive.get("bottle_name")),
normalize_name(archive.get("name")),
}
candidates.discard("")
return any(c in self.local_bottle_names for c in candidates)
# ── Busy / status ───────────────────────────────────────────
def set_busy(self, busy: bool) -> None:
self.busy = busy
self.refresh_button.set_sensitive(not busy)
self.settings_button.set_sensitive(not busy)
self.search_entry.set_sensitive(not busy)
for row in self.rows:
row.set_sensitive_state(not busy)
if busy:
self.spinner.start()
else:
self.spinner.stop()
def set_status(self, message: str, fraction: float | None = None) -> None:
self.banner.set_title(message)
self.banner.set_revealed(True)
self.progress_bar.set_visible(True)
if fraction is None:
self.progress_bar.pulse()
else:
self.progress_bar.set_fraction(max(0.0, min(1.0, fraction)))
def _hide_status(self) -> None:
self.banner.set_revealed(False)
self.progress_bar.set_visible(False)
def _toast(self, message: str, timeout: int = 3) -> None:
toast = Adw.Toast(title=message)
toast.set_timeout(timeout)
self.toast_overlay.add_toast(toast)
# ── Archive list ────────────────────────────────────────────
def clear_rows(self) -> None:
self.rows.clear()
child = self.listbox.get_first_child()
while child is not None:
nxt = child.get_next_sibling()
self.listbox.remove(child)
child = nxt
def render_archives(self, archives: list[dict[str, Any]]) -> None:
self.clear_rows()
replace_enabled = self.replace_row.get_active()
for archive in archives:
row = ArchiveRow(
archive,
self.install_archive,
is_installed=self.is_archive_installed(archive),
replace_enabled=replace_enabled,
)
row.set_sensitive_state(not self.busy)
self.rows.append(row)
self.listbox.append(row)
if self.all_archives and not archives:
self.status_page.set_icon_name("edit-find-symbolic")
self.status_page.set_title("Nessun risultato")
self.status_page.set_description(
"Nessun archivio corrisponde al filtro corrente."
)
elif not self.all_archives:
self.status_page.set_icon_name("system-software-install-symbolic")
self.status_page.set_title("Nessun archivio")
self.status_page.set_description(
"Premi il pulsante ↻ per caricare il catalogo dal server."
)
self.content_stack.set_visible_child_name(
"list" if archives else "placeholder"
)
self.update_summary(len(archives), len(self.all_archives))
def apply_archive_filter(self) -> None:
query = self.search_entry.get_text()
filtered = [
a for a in self.all_archives if archive_matches_query(a, query)
]
self.render_archives(filtered)
def _on_search_changed(self, *_args: object) -> None:
self.apply_archive_filter()
# ── Background work ─────────────────────────────────────────
def run_background(self, worker: Callable[[], None]) -> None:
threading.Thread(target=worker, daemon=True).start()
def refresh_archives(self) -> None:
if self.busy:
return
self.set_busy(True)
self.set_status("Caricamento catalogo dal server…")
def worker() -> None:
try:
archives = get_archives(self.config.server_url)
except Exception as exc:
GLib.idle_add(self._handle_error, exc)
return
GLib.idle_add(self._finish_refresh, archives)
self.run_background(worker)
def _finish_refresh(self, archives: list[dict[str, Any]]) -> bool:
self.all_archives = archives
self.refresh_local_bottles()
self.apply_archive_filter()
self._hide_status()
n = len(archives)
self._window_title.set_subtitle(
f"{n} archivi{'o' if n == 1 else ''}" if n else "Bottle Archive"
)
if archives:
self._toast(f"Catalogo aggiornato: {n} archivi{'o' if n == 1 else ''}")
else:
self._toast("Nessun archivio trovato sul server.")
self.set_busy(False)
return False
# ── Install ─────────────────────────────────────────────────
def install_archive(self, archive: dict[str, Any]) -> None:
if self.busy:
return
archive_name = (
archive.get("name")
or archive.get("bottle_name")
or f"#{archive.get('id', '?')}"
)
bottles_dir = Path(self.config.bottles_dir).expanduser()
replace_existing = self.replace_row.get_active()
self.set_busy(True)
self.set_status(f"Installazione di {archive_name}", 0.0)
def progress(message: str, fraction: float | None) -> None:
GLib.idle_add(self.set_status, message, fraction)
def worker() -> None:
try:
result = install_archive_from_metadata(
self.config.server_url,
archive,
bottles_dir,
replace_existing,
progress_callback=progress,
)
GLib.idle_add(self._finish_install, archive_name, result)
except Exception as exc:
GLib.idle_add(self._handle_error, exc)
self.run_background(worker)
def _finish_install(self, archive_name: str, result: int) -> bool:
self.set_busy(False)
self._hide_status()
if result == 0:
self.refresh_local_bottles()
self.apply_archive_filter()
self._toast(f"{archive_name} installata con successo")
else:
self._toast(
"Installazione non completata – abilita la sostituzione se la bottle esiste già.",
timeout=5,
)
return False
# ── Error handling ──────────────────────────────────────────
def _handle_error(self, exc: Exception) -> bool:
self.set_busy(False)
self._hide_status()
if isinstance(exc, urllib.error.HTTPError):
detail = exc.read().decode("utf-8", errors="ignore")
msg = f"HTTP {exc.code}: {detail or exc.reason}"
elif isinstance(exc, urllib.error.URLError):
msg = f"Errore di connessione: {exc.reason}"
else:
msg = str(exc)
self._toast(msg, timeout=5)
return False
# ── Application ─────────────────────────────────────────────────
class CellarApplication(Adw.Application):
def __init__(self) -> None:
super().__init__(application_id=APP_ID)
def do_activate(self) -> None:
_load_css()
window = self.props.active_window
if window is None:
window = CellarWindow(self)
window.present()
def main() -> int:
app = CellarApplication()
return app.run(None)
if __name__ == "__main__":
raise SystemExit(main())