#!/usr/bin/env python3
# SkillFishOS Hub — a Discover-style software centre for APT, Flatpak and Snap.
# Categories (incl. Flatpak & Snap), search, app pages with remote screenshots,
# ODRS ratings & reviews, rich metadata, sort/filter, full system updates,
# repository/source management and per-backend on/off switches. PyQt6, brass.
import sys, os, re, json, subprocess, html, hashlib, urllib.request, urllib.parse, urllib.error

from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer, QRect, QPoint, QRectF
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
                             QGridLayout, QLabel, QPushButton, QLineEdit, QScrollArea,
                             QStackedWidget, QFrame, QToolButton, QButtonGroup, QMessageBox,
                             QDialog, QFormLayout, QComboBox, QCheckBox, QPlainTextEdit,
                             QStyledItemDelegate,
                             QSizePolicy, QProgressBar, QAbstractButton, QLayout)

ICON = "/usr/share/icons/hicolor/256x256/apps/skillfishos.png"
# L'icona dell'APPLICAZIONE non e' il logo qui sopra. Il logo SkillFishOS resta
# nell'intestazione della finestra e come ripiego per i programmi senza icona,
# ma nella barra l'Hub deve mostrare la borsa steampunk del nostro tema — la
# stessa che il tema usa per Discover, di cui l'Hub prende il posto.
# Senza questa distinzione il lanciatore mostrava la borsa e la finestra aperta
# il pesce, cioe' due icone diverse per la stessa cosa.
ICON_APP = "/usr/share/icons/hicolor/256x256/apps/skillfish-hub.png"
# Le due porte privilegiate. ⚠️ Non è una duplicazione: polkit lega
# l'autorizzazione al percorso del programma, quindi due regole diverse
# vogliono due programmi. Dai nostri repository si passa da HELPER (password
# una volta per sessione), da fuori si passa da LOCALE (password ogni volta).
HELPER = "/usr/local/bin/skillfish-hub-helper"
LOCALE = "/usr/local/bin/skillfish-hub-local"
# Le azioni che cambiano di chi ci si fida: file non firmati da noi e
# sorgenti nuove.
DA_FUORI = ("deb", "flatpakref", "flatpakrepo", "flatpakbundle")
AETHERIUM = "aetherium"
CFG_DIR = os.path.expanduser("~/.config/skillfishos")
CFG_FILE = os.path.join(CFG_DIR, "hub.json")
CACHE_DIR = os.path.join(os.path.expanduser("~/.cache"), "skillfish-hub")
# Le copie che i nostri .deb installano: ripiego quando la rete non c'e'.
SHOT_DIR = "/usr/share/skillfish/screenshots"
SNAP_SOCK = "/run/snapd.socket"
ODRS = "https://odrs.gnome.org/1.0/reviews/api"

# ---------- i18n ----------
def _detect_lang():
    # Ordine standard dei locale: LC_ALL batte LC_MESSAGES batte LANG;
    # LANGUAGE (la lista con i due punti di gettext) e' l'ultimo ripiego.
    val = (os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES")
           or os.environ.get("LANG") or os.environ.get("LANGUAGE") or "")
    v = val.lower()
    if v.startswith("it"): return "it"
    if v.startswith("pl"): return "pl"
    # "uk" e' il codice ISO 639-1 della lingua ucraina. "ua" e' il codice del
    # PAESE e non dovrebbe comparire qui, ma nei locale scritti a mano capita.
    if v.startswith("uk") or v.startswith("ua"): return "uk"
    # Qualsiasi altra lingua: si restituisce il codice a due lettere e sara' il
    # dizionario condiviso (/usr/share/skillfish/i18n/<lingua>.json) a servirla.
    # ⚠️ Prima qui c'era "return en" secco: un elenco chiuso di tre lingue. Con
    # quello, aggiungere una traduzione nuova non aveva alcun effetto - la
    # lingua veniva scartata prima di arrivare al dizionario.
    codice = v.replace("-", "_").split(".")[0].split("@")[0].split("_")[0].strip()
    if len(codice) == 2 and codice.isalpha():
        return codice
    return "en"

LANG = _detect_lang()

# --- dizionario condiviso per le lingue nuove -------------------------------
# ⚠️ Non deve mai impedire alla app di partire: se il modulo o il file della
# lingua mancano, si resta all'inglese. Una traduzione assente e' un fastidio,
# una app che non si apre e' un guasto.
try:
    sys.path.insert(0, "/usr/share/skillfish")
    from i18n import traduttore as _traduttore
    _TR = _traduttore(LANG)
except Exception:
    def _TR(en):
        return en


# Traduzione polacca di Cyryl Sochacki (github.com/cyryllo).
# Le chiavi sono le stringhe INGLESI passate a L(): cosi' L(it, en) mantiene
# la stessa firma e tutte le chiamate esistenti restano valide. Una stringa
# non ancora tradotta ricade sull'inglese invece di sparire.
PL = {
    # stringhe con segnaposto (il valore entra dopo la traduzione)
    "! Operation failed (rc=%d)": "! Operacja nie powiodła się (rc=%d)",
    "%d ratings": "%d ocen",
    "%d updates available": "Dostępne aktualizacje: %d",
    "Confinement: %s": "Izolacja: %s",
    "Remove %s?": "Usunąć %s?",
    "Remove the '%s' repository?": "Usunąć repozytorium „%s”?",
    "Show more (%d left)": "Pokaż więcej (pozostało %d)",

    # top categories
    "Games": "Gry", "Development": "Programowanie", "Graphics": "Grafika",
    "Multimedia": "Multimedia", "Internet": "Internet", "Productivity": "Produktywność",
    "Education & science": "Edukacja i nauka", "System": "System", "Utilities": "Narzędzia",
    # Game sub-categories
    "Action": "Akcja", "Adventure": "Przygodowe", "Arcade": "Zręcznościowe", "Board": "Planszowe",
    "Card": "Karciane", "Kids": "Dla dzieci", "Logic": "Logiczne", "Role-playing": "Fabularne",
    "Simulation": "Symulacje", "Sports": "Sportowe", "Strategy": "Strategiczne", "Shooter": "Strzelanki",
    # Development sub-categories
    "Building": "Budowanie", "Debugger": "Debugowanie", "Version control": "Kontrola wersji",
    "Translation": "Tłumaczenie",
    # Graphics sub-categories
    "Vector": "Wektorowa", "Photography": "Fotografia", "Publishing": "Publikowanie",
    "Viewers": "Przeglądarki", "Scanning": "Skanowanie",
    # AudioVideo sub-categories
    "Recorders": "Nagrywanie", "Editing": "Edycja", "Music": "Muzyka",
    # Network sub-categories
    "Browsers": "Przeglądarki internetowe", "Messaging": "Komunikatory",
    "File transfer": "Przesyłanie plików", "Remote access": "Zdalny dostęp",
    # Office sub-categories
    "Word processors": "Procesory tekstu", "Spreadsheets": "Arkusze kalkulacyjne",
    "Presentations": "Prezentacje", "Calendar": "Kalendarz", "Finance": "Finanse",
    "Dictionaries": "Słowniki",
    # Education sub-categories
    "Astronomy": "Astronomia", "Biology": "Biologia", "Chemistry": "Chemia",
    "Geography": "Geografia", "Maths": "Matematyka", "Physics": "Fizyka",
    "Languages": "Języki", "Computer science": "Informatyka",
    # System sub-categories
    "Monitoring": "Monitorowanie", "Security": "Bezpieczeństwo", "Filesystem": "System plików",
    "Terminal": "Terminal",
    # Utility sub-categories
    "Archiving": "Archiwizacja", "Calculators": "Kalkulatory", "Text editors": "Edytory tekstu",
    "File managers": "Menedżery plików", "Clocks": "Zegary", "Accessibility": "Dostępność",
    # general UI
    "Loading the catalogue (APT + Flatpak)…": "Wczytywanie katalogu (APT + Flatpak)…",
    "Fetching ratings (ODRS)…": "Pobieranie ocen (ODRS)…",
    "Install": "Zainstaluj", "Remove": "Usuń",
    "Loading screenshots…": "Wczytywanie zrzutów ekranu…",
    "Explore": "Odkrywaj", "Installed": "Zainstalowane", "Updates": "Aktualizacje",
    "Sources": "Źródła", "CATEGORIES": "KATEGORIE", "Search…": "Szukaj…",
    "Refresh lists and check for updates": "Odśwież listy i sprawdź aktualizacje",
    "All": "Wszystkie", "Loading the software catalogue…": "Wczytywanie katalogu oprogramowania…",
    "All sources": "Wszystkie źródła", "Name": "Nazwa", "Rating": "Ocena",
    "Filter:": "Filtr:", "Sort:": "Sortuj:", "No results.": "Brak wyników.",
    "Featured · SkillFishOS": "Polecane · SkillFishOS", "Most popular": "Najpopularniejsze",
    "Editor's choice": "Wybór redakcji", "Search": "Szukaj", "Search: ": "Szukaj: ",
    "Searching…": "Wyszukiwanie…", "Open": "Otwórz", "Source": "Źródło", "Version": "Wersja",
    "Size": "Rozmiar", "License": "Licencja", "Website": "Strona internetowa",
    "What's new": "Co nowego", "Permissions": "Uprawnienia", "Channel:": "Kanał:",
    "network/ipc: ": "sieć/ipc: ", "sockets: ": "gniazda: ", "devices: ": "urządzenia: ",
    "filesystem: ": "system plików: ", "Reviews": "Recenzje", "Installing": "Instalowanie",
    "Removing": "Usuwanie", "Unsupported backend: ": "Nieobsługiwany backend: ",
    "Close": "Zamknij", "✓ Done.": "✓ Gotowe.", "Checking for updates…": "Sprawdzanie aktualizacji…",
    "Everything is up to date ✓": "Wszystko aktualne ✓", "Update all": "Aktualizuj wszystko",
    "Updating the whole system…": "Aktualizowanie całego systemu…",
    "✓ Update complete.": "✓ Aktualizacja zakończona.",
    "Refreshing the package lists…": "Odświeżanie list pakietów…",
    "Reloading…": "Ponowne wczytywanie…", "Software sources": "Źródła oprogramowania",
    "Backends": "Backendy", "(not installed)": "(nie zainstalowano)",
    "APT repositories": "Repozytoria APT", "+ Add repository": "+ Dodaj repozytorium",
    "Flatpak remotes": "Zdalne repozytoria Flatpak",
    "Backend updated. Reloading…": "Backend zaktualizowany. Ponowne wczytywanie…",
    "Add APT repository": "Dodaj repozytorium APT",
    "key .asc URL (optional)": "URL klucza .asc (opcjonalnie)",
    "Suite": "Seria", "Components": "Komponenty", "Key": "Klucz", "Add": "Dodaj",
    "Cancel": "Anuluj", "Adding the repository…": "Dodawanie repozytorium…",
    "Removing the repository…": "Usuwanie repozytorium…",
    "Repository updated. Reloading…": "Repozytorium zaktualizowane. Ponowne wczytywanie…",
    "! Failed (rc=%s)": "! Nie powiodło się (rc=%s)",
    "Add this Flatpak repository?": "Dodać to repozytorium Flatpak?",
    "Adding": "Dodawanie",
    "An update is running": "Trwa aktualizacja",
    "Architecture": "Architektura",
    "Firmware": "Oprogramowanie układowe",
    "Firmware cannot be uninstalled: if something goes wrong there is no way back on your own. Update it only if you know why you are doing it, and not while the machine is busy.": "Oprogramowania układowego nie da się odinstalować: jeśli coś pójdzie źle, samemu nie ma powrotu. Aktualizuj je tylko wtedy, gdy wiesz po co — i nie wtedy, gdy maszyna pracuje.",
    "How you want to sign it": "Jak chcesz się podpisać",
    "I could not start the operation.": "Nie udało mi się rozpocząć operacji.",
    "In one line: what is it like?": "W jednym zdaniu: jak jest?",
    "Install this Flatpak application?": "Zainstalować tę aplikację Flatpak?",
    "Install this Flatpak bundle?": "Zainstalować ten pakiet Flatpak (bundle)?",
    "Install this package?": "Zainstalować ten pakiet?",
    "Install?": "Zainstalować?",
    "Installed size": "Rozmiar po instalacji",
    "It did not go through (%s). %s": "Nie udało się (%s). %s",
    "Maintainer": "Opiekun",
    "No rating yet: how many stars?": "Jeszcze bez oceny: ile gwiazdek?",
    "No results with the «%s» filter.\nWithout it there are %d here.": "Brak wyników z filtrem «%s».\nBez niego jest ich tu %d.",
    "Origin": "Pochodzenie",
    "Publish": "Opublikuj",
    "Published. Thank you.": "Opublikowano. Dziękujemy.",
    "Repository": "Repozytorium",
    "Sending…": "Wysyłanie…",
    "Show all sources": "Pokaż wszystkie źródła",
    "The title is missing.": "Brakuje tytułu.",
    "This device does not report an id: fwupd would not know which one to update.": "To urządzenie nie podaje swojego identyfikatora: fwupd nie wiedziałby, które aktualizować.",
    "This file does not come from our repositories and none of our keys signed it: by installing it you are trusting whoever gave it to you.": "Ten plik nie pochodzi z naszych repozytoriów i nie podpisał go żaden z naszych kluczy: instalując go, ufasz temu, kto ci go dał.",
    "This goes to the public review database (ODRS), the one other software reads too: everyone will see it, and it cannot be deleted from here.": "To trafia do publicznej bazy recenzji (ODRS), tej samej, którą czytają inne programy: zobaczą to wszyscy i stąd nie da się tego usunąć.",
    "Update": "Aktualizuj",
    "Update the firmware of “%s”?\n\nIt cannot be uninstalled and there is no way back. Do not switch the machine off until it is done.": "Zaktualizować oprogramowanie układowe „%s”?\n\nNie da się go odinstalować i nie ma powrotu. Nie wyłączaj maszyny, dopóki nie skończy.",
    "Updating the firmware of %s…": "Aktualizowanie oprogramowania układowego %s…",
    "What it does well, what it does not, on which machine.": "Co robi dobrze, czego nie, na jakiej maszynie.",
    "Write a review": "Napisz recenzję",
    "You are adding a new source of software. From then on what comes from it is installed like everything else: add it only if you know whose it is.": "Dodajesz nowe źródło oprogramowania. Od tej pory to, co z niego przyjdzie, instaluje się jak wszystko inne: dodaj je tylko wtedy, gdy wiesz, czyje jest.",
    "You can close this window: the update carries on by itself, and you will find it here when you reopen the Hub.": "Możesz zamknąć to okno: aktualizacja idzie dalej sama i znajdziesz ją tutaj po ponownym otwarciu Huba.",
    "Your review of %s": "Twoja recenzja %s",
    "asks for the password every time": "pyta o hasło za każdym razem",
    "asks for the password once per session": "pyta o hasło raz na sesję",
    "asks for your password": "pyta o twoje hasło",
    "asks for your password once per session": "pyta o twoje hasło raz na sesję",
    "not allowed": "niedozwolone",
    "without asking": "bez pytania",
}

# Traduzione ucraina. NON e' opera di un madrelingua: l'ho scritta io e va
# rivista da chi parla ucraino, come il polacco e' stato rivisto da cyryllo.
# Stesso meccanismo del dizionario PL: la chiave e' la stringa inglese passata
# a L(), e una stringa non tradotta ricade sull'inglese invece di sparire.
UK = {
    # stringhe con segnaposto (il valore entra dopo la traduzione)
    "! Operation failed (rc=%d)": "! Операція не вдалася (rc=%d)",
    "%d ratings": "%d оцінок",
    "%d updates available": "Доступно оновлень: %d",
    "Confinement: %s": "Ізоляція: %s",
    "Remove %s?": "Вилучити %s?",
    "Remove the '%s' repository?": "Вилучити репозиторій «%s»?",
    "Show more (%d left)": "Показати ще (залишилось %d)",

    "Games": "Ігри",
    "Development": "Розробка",
    "Graphics": "Графіка",
    "Multimedia": "Мультимедіа",
    "Internet": "Інтернет",
    "Productivity": "Продуктивність",
    "Education & science": "Освіта та наука",
    "System": "Система",
    "Utilities": "Утиліти",
    "Action": "Екшен",
    "Adventure": "Пригоди",
    "Arcade": "Аркади",
    "Board": "Настільні",
    "Card": "Карткові",
    "Kids": "Дитячі",
    "Logic": "Логічні",
    "Role-playing": "Рольові",
    "Simulation": "Симулятори",
    "Sports": "Спортивні",
    "Strategy": "Стратегії",
    "Shooter": "Шутери",
    "Building": "Складання",
    "Debugger": "Зневаджувач",
    "Version control": "Контроль версій",
    "Translation": "Переклад",
    "Vector": "Векторна графіка",
    "Photography": "Фотографія",
    "Publishing": "Верстка",
    "Viewers": "Переглядачі",
    "Scanning": "Сканування",
    "Recorders": "Запис",
    "Editing": "Редагування",
    "Music": "Музика",
    "Browsers": "Браузери",
    "Messaging": "Обмін повідомленнями",
    "File transfer": "Передавання файлів",
    "Remote access": "Віддалений доступ",
    "Word processors": "Текстові процесори",
    "Spreadsheets": "Електронні таблиці",
    "Presentations": "Презентації",
    "Calendar": "Календар",
    "Finance": "Фінанси",
    "Dictionaries": "Словники",
    "Astronomy": "Астрономія",
    "Biology": "Біологія",
    "Chemistry": "Хімія",
    "Geography": "Географія",
    "Maths": "Математика",
    "Physics": "Фізика",
    "Languages": "Мови",
    "Computer science": "Інформатика",
    "Monitoring": "Моніторинг",
    "Security": "Безпека",
    "Filesystem": "Файлова система",
    "Terminal": "Термінал",
    "Archiving": "Архівування",
    "Calculators": "Калькулятори",
    "Text editors": "Текстові редактори",
    "File managers": "Файлові менеджери",
    "Clocks": "Годинники",
    "Accessibility": "Доступність",
    "Loading the catalogue (APT + Flatpak)…": "Завантаження каталогу (APT + Flatpak)…",
    "Fetching ratings (ODRS)…": "Отримання оцінок (ODRS)…",
    "Install": "Встановити",
    "Remove": "Вилучити",
    "Loading screenshots…": "Завантаження знімків екрана…",
    "Explore": "Огляд",
    "Installed": "Встановлені",
    "Updates": "Оновлення",
    "Sources": "Джерела",
    "CATEGORIES": "КАТЕГОРІЇ",
    "Search…": "Пошук…",
    "Refresh lists and check for updates": "Оновити списки та перевірити наявність оновлень",
    "All": "Усі",
    "Loading the software catalogue…": "Завантаження каталогу програм…",
    "All sources": "Усі джерела",
    "Name": "Назва",
    "Rating": "Оцінка",
    "Filter:": "Фільтр:",
    "Sort:": "Сортування:",
    "No results.": "Нічого не знайдено.",
    "Featured · SkillFishOS": "Рекомендовані · SkillFishOS",
    "Most popular": "Найпопулярніші",
    "Editor's choice": "Вибір редакції",
    "Search": "Пошук",
    "Search: ": "Пошук: ",
    "Searching…": "Триває пошук…",
    "Open": "Відкрити",
    "Source": "Джерело",
    "Version": "Версія",
    "Size": "Розмір",
    "License": "Ліцензія",
    "Website": "Вебсайт",
    "What's new": "Що нового",
    "Permissions": "Дозволи",
    "Channel:": "Канал:",
    "network/ipc: ": "мережа/ipc: ",
    "sockets: ": "сокети: ",
    "devices: ": "пристрої: ",
    "filesystem: ": "файлова система: ",
    "Reviews": "Відгуки",
    "Installing": "Встановлення",
    "Removing": "Вилучення",
    "Unsupported backend: ": "Непідтримуваний бекенд: ",
    "Close": "Закрити",
    "✓ Done.": "✓ Готово.",
    "Checking for updates…": "Перевірка оновлень…",
    "Everything is up to date ✓": "Усе оновлено ✓",
    "Update all": "Оновити все",
    "Updating the whole system…": "Оновлення всієї системи…",
    "✓ Update complete.": "✓ Оновлення завершено.",
    "Refreshing the package lists…": "Оновлення списків пакунків…",
    "Reloading…": "Перезавантаження…",
    "Software sources": "Джерела програм",
    "Backends": "Бекенди",
    "(not installed)": "(не встановлено)",
    "APT repositories": "Репозиторії APT",
    "+ Add repository": "+ Додати репозиторій",
    "Flatpak remotes": "Віддалені джерела Flatpak",
    "Backend updated. Reloading…": "Бекенд оновлено. Перезавантаження…",
    "Add APT repository": "Додати репозиторій APT",
    "key .asc URL (optional)": "URL ключа .asc (необов’язково)",
    "Suite": "Випуск (suite)",
    "Components": "Компоненти",
    "Key": "Ключ",
    "Add": "Додати",
    "Cancel": "Скасувати",
    "Adding the repository…": "Додавання репозиторію…",
    "Removing the repository…": "Вилучення репозиторію…",
    "Repository updated. Reloading…": "Репозиторій оновлено. Перезавантаження…",
    "! Failed (rc=%s)": "! Не вдалося (rc=%s)",
    "Add this Flatpak repository?": "Додати це сховище Flatpak?",
    "Adding": "Додавання",
    "An update is running": "Триває оновлення",
    "Architecture": "Архітектура",
    "Firmware": "Мікропрограма",
    "Firmware cannot be uninstalled: if something goes wrong there is no way back on your own. Update it only if you know why you are doing it, and not while the machine is busy.": "Мікропрограму не вилучити: якщо щось піде не так, самому назад не повернутися. Оновлюйте її лише тоді, коли знаєте навіщо, і не тоді, коли машина працює.",
    "How you want to sign it": "Як підписатися",
    "I could not start the operation.": "Мені не вдалося почати операцію.",
    "In one line: what is it like?": "В одному рядку: як воно?",
    "Install this Flatpak application?": "Встановити цей застосунок Flatpak?",
    "Install this Flatpak bundle?": "Встановити цей пакунок Flatpak (bundle)?",
    "Install this package?": "Встановити цей пакунок?",
    "Install?": "Встановити?",
    "Installed size": "Розмір після встановлення",
    "It did not go through (%s). %s": "Не вийшло (%s). %s",
    "Maintainer": "Супровідник",
    "No rating yet: how many stars?": "Оцінки ще немає: скільки зірок?",
    "No results with the «%s» filter.\nWithout it there are %d here.": "Немає результатів з фільтром «%s».\nБез нього тут їх %d.",
    "Origin": "Джерело",
    "Publish": "Опублікувати",
    "Published. Thank you.": "Опубліковано. Дякуємо.",
    "Repository": "Сховище",
    "Sending…": "Надсилання…",
    "Show all sources": "Показати всі джерела",
    "The title is missing.": "Бракує заголовка.",
    "This device does not report an id: fwupd would not know which one to update.": "Цей пристрій не повідомляє свого ідентифікатора: fwupd не знав би, який оновлювати.",
    "This file does not come from our repositories and none of our keys signed it: by installing it you are trusting whoever gave it to you.": "Цей файл не з наших сховищ, і його не підписав жоден наш ключ: встановлюючи його, ви довіряєте тому, хто вам його дав.",
    "This goes to the public review database (ODRS), the one other software reads too: everyone will see it, and it cannot be deleted from here.": "Це піде до загальнодоступної бази відгуків (ODRS), тієї самої, яку читають інші програми: побачать усі, і звідси видалити не можна.",
    "Update": "Оновити",
    "Update the firmware of “%s”?\n\nIt cannot be uninstalled and there is no way back. Do not switch the machine off until it is done.": "Оновити мікропрограму «%s»?\n\nЇї не вилучити, і назад дороги немає. Не вимикайте машину, доки не завершить.",
    "Updating the firmware of %s…": "Оновлення мікропрограми %s…",
    "What it does well, what it does not, on which machine.": "Що виходить добре, що ні, на якій машині.",
    "Write a review": "Написати відгук",
    "You are adding a new source of software. From then on what comes from it is installed like everything else: add it only if you know whose it is.": "Ви додаєте нове джерело програм. Відтепер усе, що звідти прийде, встановлюється як і решта: додавайте, лише якщо знаєте, чиє воно.",
    "You can close this window: the update carries on by itself, and you will find it here when you reopen the Hub.": "Це вікно можна закрити: оновлення йде своїм ходом, і ви знайдете його тут, коли знову відкриєте Hub.",
    "Your review of %s": "Ваш відгук про %s",
    "asks for the password every time": "запитує пароль щоразу",
    "asks for the password once per session": "запитує пароль один раз за сеанс",
    "asks for your password": "запитує ваш пароль",
    "asks for your password once per session": "запитує ваш пароль один раз за сеанс",
    "not allowed": "не дозволено",
    "without asking": "не запитуючи",
}


def L(it, en):
    """Restituisce la stringa IT, PL o EN secondo il locale rilevato."""
    if LANG == "it": return it
    if LANG == "pl": return PL.get(en) or _TR(en)
    if LANG == "uk": return UK.get(en) or _TR(en)
    return _TR(en)

def run(cmd, t=120):
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=t)
        return r.returncode, (r.stdout or ""), (r.stderr or "")
    except Exception as e:
        return 1, "", str(e)

def have(b):
    from shutil import which
    return which(b) is not None

def load_cfg():
    try:
        with open(CFG_FILE) as f:
            return json.load(f)
    except Exception:
        return {}
def save_cfg(d):
    try:
        os.makedirs(CFG_DIR, exist_ok=True)
        with open(CFG_FILE, "w") as f:
            json.dump(d, f, indent=2)
    except Exception:
        pass  # best-effort: settings persistence is non-critical

def snapd_get(path):
    """Query the local snapd REST API over its unix socket; return parsed JSON or {}."""
    if not os.path.exists(SNAP_SOCK):
        return {}
    rc, out, _ = run(["curl", "-s", "--max-time", "20", "--unix-socket", SNAP_SOCK,
                      "http://localhost" + path], 25)
    try:
        return json.loads(out)
    except Exception:
        return {}

# Discover-style top categories -> freedesktop members + snapd category names
CATEGORIES = [
    ("Game",        L("Giochi", "Games"),              "🎮", {"Game"}, ["games"]),
    ("Development", L("Sviluppo", "Development"),       "🛠️", {"Development"}, ["development"]),
    ("Graphics",    L("Grafica", "Graphics"),           "🎨", {"Graphics"}, ["art-and-design"]),
    ("AudioVideo",  L("Multimedia", "Multimedia"),      "🎬", {"AudioVideo", "Audio", "Video"}, ["music-and-audio", "photo-and-video"]),
    ("Network",     L("Internet", "Internet"),          "🌐", {"Network"}, ["social", "news-and-weather"]),
    ("Office",      L("Produttività", "Productivity"),  "📝", {"Office"}, ["productivity", "finance"]),
    ("Education",   L("Istruzione e scienza", "Education & science"), "📚", {"Education", "Science"}, ["education", "science"]),
    ("System",      L("Sistema", "System"),             "⚙️", {"System", "Settings"}, ["devices-and-iot", "server-and-cloud"]),
    ("Utility",     L("Utilità", "Utilities"),          "🧰", {"Utility", "Accessibility"}, ["utilities", "personalisation"]),
]
CAT_NAME = {c[0]: c[1] for c in CATEGORIES}
CAT_SNAP = {c[0]: c[4] for c in CATEGORIES}

# Sub-categories (freedesktop additional categories) per top category, like Discover.
SUBCATS = {
    "Game":        [("ActionGame", L("Azione", "Action")), ("AdventureGame", L("Avventura", "Adventure")), ("ArcadeGame", L("Arcade", "Arcade")), ("BoardGame", L("Da tavolo", "Board")), ("CardGame", L("Carte", "Card")), ("KidsGame", L("Bambini", "Kids")), ("LogicGame", L("Logica", "Logic")), ("RolePlaying", L("Ruolo", "Role-playing")), ("Simulation", L("Simulazione", "Simulation")), ("SportsGame", L("Sport", "Sports")), ("StrategyGame", L("Strategia", "Strategy")), ("Shooter", L("Sparatutto", "Shooter"))],
    "Development": [("IDE", "IDE"), ("Building", L("Build", "Building")), ("Debugger", L("Debug", "Debugger")), ("RevisionControl", L("Versionamento", "Version control")), ("WebDevelopment", "Web"), ("Database", "Database"), ("Translation", L("Traduzione", "Translation"))],
    "Graphics":    [("2DGraphics", "2D"), ("3DGraphics", "3D"), ("VectorGraphics", L("Vettoriale", "Vector")), ("RasterGraphics", "Raster"), ("Photography", L("Foto", "Photography")), ("Publishing", L("Editoria", "Publishing")), ("Viewer", L("Visualizzatori", "Viewers")), ("Scanning", L("Scansione", "Scanning"))],
    "AudioVideo":  [("Audio", "Audio"), ("Video", "Video"), ("Player", "Player"), ("Recorder", L("Registrazione", "Recorders")), ("AudioVideoEditing", L("Editing", "Editing")), ("Music", L("Musica", "Music")), ("Mixer", "Mixer")],
    "Network":     [("WebBrowser", L("Browser", "Browsers")), ("Email", "Email"), ("Chat", "Chat"), ("InstantMessaging", L("Messaggistica", "Messaging")), ("FileTransfer", L("Trasferimento", "File transfer")), ("News", "News"), ("RemoteAccess", L("Accesso remoto", "Remote access"))],
    "Office":      [("WordProcessor", L("Testi", "Word processors")), ("Spreadsheet", L("Fogli di calcolo", "Spreadsheets")), ("Presentation", L("Presentazioni", "Presentations")), ("Calendar", L("Calendario", "Calendar")), ("Database", "Database"), ("Finance", L("Finanza", "Finance")), ("Dictionary", L("Dizionari", "Dictionaries"))],
    "Education":   [("Astronomy", L("Astronomia", "Astronomy")), ("Biology", L("Biologia", "Biology")), ("Chemistry", L("Chimica", "Chemistry")), ("Geography", L("Geografia", "Geography")), ("Math", L("Matematica", "Maths")), ("Physics", L("Fisica", "Physics")), ("Languages", L("Lingue", "Languages")), ("ComputerScience", L("Informatica", "Computer science"))],
    "System":      [("Monitor", L("Monitoraggio", "Monitoring")), ("Security", L("Sicurezza", "Security")), ("Filesystem", L("File system", "Filesystem")), ("TerminalEmulator", L("Terminale", "Terminal"))],
    "Utility":     [("Archiving", L("Archiviazione", "Archiving")), ("Calculator", L("Calcolatrice", "Calculators")), ("TextEditor", L("Editor di testo", "Text editors")), ("FileManager", L("File manager", "File managers")), ("Clock", L("Orologio", "Clocks")), ("Accessibility", L("Accessibilità", "Accessibility"))],
}
def top_category_of(cats):
    s = set(cats or [])
    for key, _n, _e, members, _sn in CATEGORIES:
        if s & members:
            return key
    return "Utility"

SRC_LABEL = {"apt": "APT", "flatpak": "Flatpak", "snap": "Snap"}


# Le tendine: lo stile del progetto, elenco aperto compreso.
#
# ⚠️ Vestire QComboBox non basta: l'elenco che si apre e' un widget suo
# (QAbstractItemView) e senza una regola propria resta con l'aspetto di sistema,
# fondo chiaro e testo nero in mezzo all'ottone.
STILE_TENDINA = """
QComboBox{background:#171206;color:#f1e3c6;border:1px solid rgba(216,168,73,0.30);
          border-radius:9px;padding:5px 30px 5px 11px;min-height:20px;}
QComboBox:hover{border-color:rgba(216,168,73,0.60);background:#1d1608;}
QComboBox:focus{border-color:#d8a849;}
QComboBox:on{border-color:#d8a849;background:#1d1608;}
QComboBox::drop-down{border:none;width:26px;subcontrol-origin:padding;subcontrol-position:center right;}
QComboBox::down-arrow{image:none;width:0;height:0;margin-right:10px;
                      border-left:5px solid transparent;border-right:5px solid transparent;
                      border-top:6px solid rgba(216,168,73,0.85);}
QComboBox::down-arrow:on{border-top:none;border-bottom:6px solid #d8a849;}
QComboBox QAbstractItemView{background:#1c150c;color:#f1e3c6;
                            border:1px solid rgba(216,168,73,0.45);border-radius:9px;
                            padding:5px;outline:none;
                            selection-background-color:rgba(216,168,73,0.30);
                            selection-color:#fff3d6;}
QComboBox QAbstractItemView::item{padding:7px 12px;border-radius:6px;min-height:22px;}
QComboBox QAbstractItemView::item:selected{background:rgba(216,168,73,0.30);color:#fff3d6;}
QComboBox QAbstractItemView::item:hover{background:rgba(216,168,73,0.16);}
"""


def vesti_tendina(c):
    u"""Applica lo stile a una tendina, elenco compreso.

    Il delegato non e' un vezzo: con quello predefinito parecchi stili Qt
    ignorano il foglio di stile sulle VOCI, e rientro e colore di selezione non
    si vedono — sembra che lo stile non funzioni mentre e' Qt che non lo guarda.
    """
    c.setStyleSheet(STILE_TENDINA)
    try:
        c.setItemDelegate(QStyledItemDelegate(c))
    except Exception as e:
        # Non e' fatale: la tendina funziona lo stesso, solo con le voci meno
        # rifinite. Ma se succede vogliamo saperlo, perche' vuol dire che lo
        # stile non si applica e sembrera' che il foglio di stile sia rotto.
        print("hub: delegato della tendina non applicato (%s)" % e, file=sys.stderr)
    v = c.view()
    if v is not None:
        v.setSpacing(1)
    return c


class App:
    __slots__ = ("backend", "appid", "pkgid", "name", "summary", "description",
                 "icon", "screens", "cats", "installed", "ver", "ver_inst", "origin",
                 "license", "developer", "homepage", "size_dl", "size_inst",
                 "confinement", "channels", "odrs_id", "rating", "rating_n", "releases",
                 "_snap_icon_url")
    def __init__(self, backend, appid, pkgid, name, summary="", description="",
                 icon=None, screens=None, cats=None, installed=False, ver="", ver_inst="",
                 origin="", license="", developer="", homepage="", size_dl=0, size_inst=0,
                 confinement="", channels=None, odrs_id="", releases=None):
        self.backend = backend; self.appid = appid; self.pkgid = pkgid
        self.name = name; self.summary = summary; self.description = description
        self.icon = icon; self.screens = screens or []; self.cats = cats or []
        self.installed = installed; self.ver = ver; self.ver_inst = ver_inst; self.origin = origin
        self.license = license; self.developer = developer; self.homepage = homepage
        self.size_dl = size_dl; self.size_inst = size_inst
        self.confinement = confinement; self.channels = channels or {}
        self.odrs_id = odrs_id or appid; self.rating = 0.0; self.rating_n = 0
        self.releases = releases or []
    @property
    def key(self):
        return "%s:%s" % (self.backend, self.appid)


# ============================ CATALOG ============================
class Catalog:
    def __init__(self):
        self.apps = {}
        self.by_cat = {}
        self.ratings = {}     # odrs id -> (avg, total)

    def load_appstream(self):
        try:
            import gi; gi.require_version("AppStream", "1.0")
            from gi.repository import AppStream as AS
        except Exception:
            return
        try:
            import apt; cache = apt.Cache()
        except Exception:
            cache = None
        pool = AS.Pool()
        try:
            pool.set_flags(AS.PoolFlags.LOAD_OS_CATALOG | AS.PoolFlags.LOAD_OS_METAINFO | AS.PoolFlags.LOAD_FLATPAK)
        except Exception as e:
            print("appstream: set_flags fallback to defaults:", e, file=sys.stderr)
        try:
            pool.load()
        except Exception as e:
            print("appstream: pool load failed (catalogue will be empty):", e, file=sys.stderr)
        # installed flatpak refs
        fp_inst = {}
        if have("flatpak"):
            rc, out, _ = run(["flatpak", "list", "--app", "--columns=application,version"], 25)
            for ln in out.splitlines():
                f = ln.split("\t")
                if f and f[0]: fp_inst[f[0].strip()] = (f[1].strip() if len(f) > 1 else "")
        comps = pool.get_components()
        arr = comps.as_array() if hasattr(comps, "as_array") else list(comps)
        for c in arr:
            try:
                # ⚠️ Non solo le applicazioni con finestra: un pacchetto di
                # servizio (il tema, la base, il portachiavi) puo' avere la sua
                # scheda, ed e' l'unico modo perche' la sua descrizione esista
                # in una lingua diversa dall'inglese.
                if c.get_kind() not in (AS.ComponentKind.DESKTOP_APP,
                                        AS.ComponentKind.GENERIC,
                                        AS.ComponentKind.ADDON):
                    continue
                bundle = None
                try: bundle = c.get_bundle(AS.BundleKind.FLATPAK)
                except Exception: bundle = None
                if bundle is not None:
                    a = self._mk(AS, c, "flatpak", bundle.get_id(), bundle.get_id())
                    # ⚠️ bundle.get_id() e' il riferimento LUNGO,
                    # «app/<id>/<architettura>/<ramo>», mentre «flatpak list
                    # --columns=application» stampa il solo <id>. Confrontarli
                    # cosi' com'erano non riusciva mai, e nella scheda
                    # «Installati» non compariva un solo flatpak nemmeno
                    # avendone quindici sul disco.
                    rif = bundle.get_id() or ""
                    ident = (rif.split("/")[1]
                             if rif.startswith("app/") and rif.count("/") >= 2
                             else rif)
                    a.installed = (bool(ident) and ident in fp_inst) or rif in fp_inst \
                        or (c.get_id() or "") in fp_inst
                    if a.installed:
                        a.ver_inst = fp_inst.get(ident) or fp_inst.get(rif) or ""
                    a.origin = "flathub"
                else:
                    pkg = c.get_pkgname()
                    if not pkg or (cache is not None and pkg not in cache):
                        continue
                    a = self._mk(AS, c, "apt", c.get_id(), pkg)
                    if cache is not None and pkg in cache:
                        p = cache[pkg]; a.installed = p.is_installed
                        if p.candidate:
                            a.ver = p.candidate.version; a.size_dl = p.candidate.size; a.size_inst = p.candidate.installed_size
                            a.origin = next((o.archive for o in p.candidate.origins if o.archive), "")
                        if p.installed: a.ver_inst = p.installed.version
                self.apps[a.key] = a
            except Exception:
                continue  # best-effort: skip malformed components
        # our own aetherium packages even without AppStream metadata
        if cache is not None:
            import glob as _glob
            have_ids = {a.pkgid for a in self.apps.values() if a.backend == "apt"}
            for pf in _glob.glob("/var/lib/apt/lists/*aetherium*Packages"):
                try:
                    with open(pf, errors="replace") as f:
                        for line in f:
                            if not line.startswith("Package: "): continue
                            nm = line[9:].strip()
                            if nm in have_ids or nm not in cache: continue
                            p = cache[nm]; cand = p.candidate
                            a = App("apt", "skf:" + nm, nm, nm,
                                    summary=(cand.summary if cand else "") or "",
                                    description=(cand.description if cand else "") or "",
                                    installed=p.is_installed, ver=cand.version if cand else "",
                                    ver_inst=p.installed.version if p.installed else "", origin="aetherium",
                                    size_dl=cand.size if cand else 0, size_inst=cand.installed_size if cand else 0)
                            self.apps[a.key] = a; have_ids.add(nm)
                except Exception:
                    pass  # best-effort
        # snap installed
        if have("snap"):
            rc, out, _ = run(["snap", "list"], 20)
            for ln in out.splitlines()[1:]:
                f = ln.split()
                if not f: continue
                key = "snap:" + f[0]
                if key not in self.apps:
                    self.apps[key] = App("snap", f[0], f[0], f[0], installed=True,
                                         ver=f[1] if len(f) > 1 else "", origin="snap")
                else:
                    self.apps[key].installed = True
        self._reindex()

    def _mk(self, AS, c, backend, appid, pkgid):
        icon = None
        ic = c.get_icon_by_size(64, 64) or None
        if ic is None:
            ics = c.get_icons()
            ia = ics.as_array() if (ics and hasattr(ics, "as_array")) else (list(ics) if ics else [])
            ic = ia[0] if ia else None
        if ic and hasattr(ic, "get_url") and ic.get_url():
            u = ic.get_url(); icon = u[7:] if u.startswith("file://") else u
        lic = ""; dev = ""; home = ""
        try: lic = c.get_project_license() or ""
        except Exception: pass  # best-effort
        try:
            d = c.get_developer()
            dev = (d.get_name() if d else "") or ""
        except Exception:
            pass  # best-effort
        try: home = c.get_url(AS.UrlKind.HOMEPAGE) or ""
        except Exception: pass  # best-effort
        a = App(backend, appid, pkgid, c.get_name() or pkgid, c.get_summary() or "",
                c.get_description() or "", icon, self._screens(c), list(c.get_categories() or []),
                license=lic, developer=dev, homepage=home, odrs_id=c.get_id())
        a.releases = self._releases(c)
        return a

    def _screens(self, c):
        out = []
        try:
            scr = c.get_screenshots_all()
            sa = scr.as_array() if hasattr(scr, "as_array") else list(scr)
            for s in sa:
                imgs = s.get_images()
                ia = imgs.as_array() if hasattr(imgs, "as_array") else list(imgs)
                best = None; bw = 0
                for im in ia:
                    w = im.get_width() or 0
                    if w >= bw: bw = w; best = im.get_url()
                if best:
                    out.append(best[7:] if best.startswith("file://") else best)
        except Exception:
            pass  # best-effort
        return out[:6]

    def _releases(self, c):
        out = []
        try:
            rl = c.get_releases_plain() if hasattr(c, "get_releases_plain") else c.get_releases()
            entries = rl.get_entries() if hasattr(rl, "get_entries") else (rl.as_array() if hasattr(rl, "as_array") else list(rl))
            for r in entries[:4]:
                out.append((r.get_version() or "", (r.get_description() or "")))
        except Exception:
            pass  # best-effort
        return out

    def load_ratings(self):
        # ODRS aggregate ratings, with a 24h on-disk cache: skips a multi-MB
        # download on every launch and keeps stars working offline.
        cache = os.path.join(os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")),
                             "skillfish-hub", "odrs-ratings.json")
        data = None
        try:
            import time as _t
            if os.path.exists(cache) and (_t.time() - os.path.getmtime(cache)) < 86400:
                with open(cache, encoding="utf-8") as fh:
                    data = json.load(fh)
        except Exception:
            data = None  # unreadable cache: refetch below
        if data is None:
            try:
                with urllib.request.urlopen(ODRS + "/ratings", timeout=15) as r:
                    data = json.loads(r.read().decode())
                os.makedirs(os.path.dirname(cache), exist_ok=True)
                with open(cache, "w", encoding="utf-8") as fh:
                    json.dump(data, fh)
            except Exception:
                # offline / ODRS down: fall back to a stale cache if we have one
                try:
                    with open(cache, encoding="utf-8") as fh:
                        data = json.load(fh)
                except Exception:
                    data = None
        try:
            for k, v in (data or {}).items():
                tot = v.get("total", 0)
                if tot:
                    avg = sum(int(k2[-1]) * v.get(k2, 0) for k2 in ("star1", "star2", "star3", "star4", "star5")) / tot
                    self.ratings[k] = (avg, tot)
        except Exception:
            pass  # best-effort: ratings are optional
        for a in self.apps.values():
            for cand in (a.odrs_id, a.odrs_id + ".desktop"):
                if cand in self.ratings:
                    a.rating, a.rating_n = self.ratings[cand]; break

    def _reindex(self):
        self.by_cat = {}
        for a in self.apps.values():
            if a.backend == "snap":
                continue
            k = top_category_of(a.cats)
            self.by_cat.setdefault(k, []).append(a)
        for k in self.by_cat:
            self.by_cat[k].sort(key=lambda a: a.name.lower())

    def snap_category(self, topkey):
        out = []
        for sc in CAT_SNAP.get(topkey, []):
            d = snapd_get("/v2/find?category=" + sc)
            for s in d.get("result", []):
                name = s.get("name")
                if not name: continue
                key = "snap:" + name
                if key in self.apps:
                    out.append(self.apps[key]); continue
                a = self._snap_app(s)
                self.apps[key] = a; out.append(a)
        seen = set(); uniq = []
        for a in out:
            if a.key in seen: continue
            seen.add(a.key); uniq.append(a)
        return uniq

    def _snap_app(self, s):
        media = s.get("media", []) or []
        icon = next((m.get("url") for m in media if m.get("type") == "icon"), None)
        shots = [m.get("url") for m in media if m.get("type") == "screenshot"]
        a = App("snap", s.get("name"), s.get("name"), s.get("title") or s.get("name"),
                s.get("summary") or "", s.get("description") or "", None, shots, [],
                installed=(s.get("status") == "installed"), ver=s.get("version", ""),
                origin="snap", license=s.get("license", "") or "",
                developer=(s.get("publisher", {}) or {}).get("display-name", "") or s.get("developer", ""),
                size_dl=s.get("download-size", 0) or 0, confinement=s.get("confinement", ""))
        a._snap_icon_url = icon if icon else None
        return a

    def search(self, q):
        ql = q.lower().strip(); res = []
        for a in self.apps.values():
            if a.backend in ("apt", "flatpak") and (ql in a.name.lower() or ql in (a.summary or "").lower() or ql in a.pkgid.lower()):
                res.append(a)
        if have("snap"):
            d = snapd_get("/v2/find?" + urllib.parse.urlencode({"q": q}))
            for s in d.get("result", [])[:40]:
                key = "snap:" + (s.get("name") or "")
                if key in self.apps: res.append(self.apps[key]); continue
                a = self._snap_app(s); self.apps[key] = a; res.append(a)
        seen = set(); uniq = []
        for a in res:
            if a.key in seen: continue
            seen.add(a.key); uniq.append(a)
        return uniq

    def updates(self):
        u"""Cosa c'e' da aggiornare, sorgente per sorgente.

        ⚠️ Le voci hanno CINQUE campi e il quinto vale solo per il firmware:
        e' l'identificativo del dispositivo, l'unica cosa con cui fwupd sa quale
        aggiornare. Per apt, flatpak e snap resta vuoto.
        """
        ups = []
        if have("apt-get"):
            rc, out, _ = run(["apt-get", "-s", "full-upgrade"], 60)
            for ln in out.splitlines():
                m = re.match(r'Inst (\S+) \[([^\]]*)\] \(([^ ]+)', ln)
                if m: ups.append(("apt", m.group(1), m.group(2), m.group(3), ""))
        if have("flatpak"):
            # ⚠️ ANCHE IL RAMO, non solo l'identificativo. Senza, GL.default
            # 24.08 e 25.08 sono due righe identiche, e org.kde.Platform 6.9 e
            # 6.10 pure: sembrano la stessa cosa elencata due volte che non si
            # aggiorna mai. Un runtime spesso non ha una versione da mostrare,
            # e allora il ramo e' l'unica cosa che distingue le due righe.
            rc, out, _ = run(["flatpak", "remote-ls", "--updates",
                              "--columns=application,version,branch"], 40)
            for ln in out.splitlines():
                f = ln.split("\t")
                if f and f[0] and f[0] != "Application ID":
                    ramo = f[2].strip() if len(f) > 2 else ""
                    nome = "%s (%s)" % (f[0], ramo) if ramo else f[0]
                    ups.append(("flatpak", nome, "", f[1] if len(f) > 1 else "", ""))
        if have("snap"):
            rc, out, _ = run(["snap", "refresh", "--list"], 40)
            for ln in out.splitlines()[1:]:
                f = ln.split()
                if f: ups.append(("snap", f[0], "", f[1] if len(f) > 1 else "", ""))
        ups.extend(self.firmware())
        return ups

    @staticmethod
    def firmware():
        u"""I dispositivi che hanno un firmware nuovo.

        E' la cosa che Discover faceva e noi no, ed e' anche quella da trattare
        con piu' rispetto: un pacchetto sbagliato si reinstalla, un firmware
        sbagliato lo si porta in assistenza. Per questo qui si LEGGE soltanto —
        l'aggiornamento vero parte da un pulsante suo, uno per dispositivo, e
        non dal pulsante «Aggiorna tutto».

        ⚠️ `fwupdmgr get-updates` esce con codice diverso da zero quando non c'e'
        niente da aggiornare: non e' un errore e non va trattato come tale.
        """
        if not have("fwupdmgr"):
            return []
        rc, out, _ = run(["fwupdmgr", "get-updates", "--json"], 60)
        try:
            dati = json.loads(out or "{}")
        except ValueError:
            return []
        fuori = []
        for d in (dati.get("Devices") or []):
            versioni = d.get("Releases") or []
            nuova = (versioni[0] or {}).get("Version", "") if versioni else ""
            fuori.append(("firmware", d.get("Name") or "?", d.get("Version", ""),
                          nuova, d.get("DeviceId", "")))
        return fuori

# ============================ THREADS ============================
class Loader(QThread):
    done = pyqtSignal(object); msg = pyqtSignal(str)
    def run(self):
        c = Catalog()
        self.msg.emit(L("Carico il catalogo (APT + Flatpak)…", "Loading the catalogue (APT + Flatpak)…"))
        c.load_appstream()
        self.msg.emit(L("Scarico valutazioni (ODRS)…", "Fetching ratings (ODRS)…"))
        c.load_ratings()
        self.done.emit(c)

class Worker(QThread):
    done = pyqtSignal(object)
    def __init__(self, fn): super().__init__(); self.fn = fn
    def run(self):
        try: self.done.emit(self.fn())
        except Exception: self.done.emit(None)

class Op(QThread):
    line = pyqtSignal(str); pct = pyqtSignal(int); done = pyqtSignal(int)
    def __init__(self, argv, t=3600): super().__init__(); self.argv = argv; self.t = t
    def run(self):
        try:
            p = subprocess.Popen(self.argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
            for ln in iter(p.stdout.readline, ""):
                if not ln: continue
                self.line.emit(ln.rstrip())
                m = re.search(r'(\d{1,3})%', ln)
                if m:
                    v = int(m.group(1))
                    if 0 <= v <= 100: self.pct.emit(v)
            p.wait(self.t); self.done.emit(p.returncode)
        except Exception as e:
            self.line.emit(str(e)); self.done.emit(1)

class ImgLoad(QThread):
    ready = pyqtSignal(int, QPixmap)
    def __init__(self, urls): super().__init__(); self.urls = urls
    def run(self):
        os.makedirs(CACHE_DIR, exist_ok=True)
        for i, url in enumerate(self.urls):
            pm = QPixmap()
            try:
                if url.startswith("http"):
                    cf = os.path.join(CACHE_DIR, hashlib.sha1(url.encode()).hexdigest() + ".img")
                    if not os.path.exists(cf):
                        req = urllib.request.Request(url, headers={"User-Agent": "SkillFishOS-Hub"})
                        with urllib.request.urlopen(req, timeout=20) as r, open(cf, "wb") as o:
                            o.write(r.read())
                    pm.load(cf)
                else:
                    # ⚠️ AppStream torna l'URI INTERO: per uno screenshot locale
                    # e' «file:///usr/share/...», e os.path.exists() su quella
                    # stringa e' falso sempre. Era per questo che il carosello
                    # restava vuoto su tutte le nostre app.
                    p = url[7:] if url.startswith("file://") else url
                    if os.path.exists(p):
                        pm.load(p)
            except Exception:
                pm = QPixmap()
            if pm.isNull():
                # Ultima spiaggia: la copia che il pacchetto ha installato. Serve
                # a chi non ha rete, o quando il sito non risponde.
                loc = os.path.join(SHOT_DIR, os.path.basename(url.split("?")[0]))
                if os.path.exists(loc):
                    pm.load(loc)
            if not pm.isNull():
                self.ready.emit(i, pm)


# ============================ LA TRANSAZIONE ============================
# ⚠️ L'AGGIORNAMENTO NON E' PIU' FIGLIO DELLA FINESTRA.
# Prima l'apt girava come processo figlio dell'Hub, con l'uscita in una pipe:
# bastava che la finestra morisse perche' la pipe si chiudesse e l'apt se ne
# andasse a meta' strada, con dpkg in mezzo al guado. E la finestra muore
# facile, perche' l'aggiornamento sostituisce sotto di lei le librerie Qt —
# quando non aggiorna l'Hub stesso.
# Ora il lavoro sta in una unita' systemd transitoria che non e' figlia di
# nessuno: qui si LEGGE il suo registro. Chiudere la finestra non ferma niente,
# e riaprendo l'Hub ci si riattacca a quello che sta girando.
STATO_DIR = "/var/lib/skillfish/hub"
TX_LOG = os.path.join(STATO_DIR, "tx.log")
TX_JSON = os.path.join(STATO_DIR, "tx.json")
CONTEGGIO = os.path.join(STATO_DIR, "updates.json")


def _leggi_json(percorso):
    try:
        with open(percorso) as f:
            return json.load(f)
    except Exception:
        return {}


def tx_stato():
    u"""Che sta facendo la transazione: {} se non ce n'e' mai stata una."""
    return _leggi_json(TX_JSON)


def tx_in_corso():
    return tx_stato().get("stato") == "in-corso"


def tx_avvia(azione, *argomenti):
    u"""Chiede all'helper di avviarla. Torna (ok, messaggio).

    Non aspetta la fine — sarebbe il contrario di tutto questo: torna appena la
    transazione e' partita, e a seguirla ci pensa la finestra.
    """
    porta = LOCALE if azione in DA_FUORI else HELPER
    rc, out, err = run(["pkexec", porta, "tx-start", azione] + list(argomenti), 120)
    if rc == 3:   # ne sta gia' girando una: ci si attacca a quella
        return True, ""
    return rc == 0, (err or out).strip()


def conteggio():
    u"""Quanti aggiornamenti aveva contato l'ultimo giro del timer."""
    return _leggi_json(CONTEGGIO)


class SeguiTransazione(QDialog):
    u"""Guarda una transazione che gira per conto suo.

    Non lancia niente e non uccide niente: apre il registro, legge i byte
    aggiunti da quando ha guardato l'ultima volta e li mostra. Per questo il
    pulsante «Chiudi» e' attivo da subito — chiudere la finestra qui non ha
    nessun effetto sull'aggiornamento, ed e' il motivo per cui l'abbiamo
    staccato.
    """
    def __init__(self, parent, titolo, alla_fine=None):
        super().__init__(parent)
        self.setWindowTitle("SkillFishOS Hub")
        self.resize(720, 470)
        self._alla_fine = alla_fine
        self._pos = 0
        v = QVBoxLayout(self)
        t = QLabel(titolo)
        t.setStyleSheet("color:#e8c878;font-size:15px;font-weight:700;")
        v.addWidget(t)
        nota = QLabel(L("Puoi chiudere questa finestra: l'aggiornamento va avanti "
                        "per conto suo e lo ritrovi qui riaprendo l'Hub.",
                        "You can close this window: the update carries on by "
                        "itself, and you will find it here when you reopen the Hub."))
        nota.setWordWrap(True)
        nota.setStyleSheet("color:#b9a07a;font-size:12px;")
        v.addWidget(nota)
        self.log = QPlainTextEdit()
        self.log.setReadOnly(True)
        self.log.setStyleSheet("QPlainTextEdit{background:#120f0a;color:#d9c8a8;"
                               "border:1px solid rgba(216,168,73,0.18);border-radius:8px;}")
        v.addWidget(self.log)
        self.bar = QProgressBar()
        self.bar.setRange(0, 0)
        v.addWidget(self.bar)
        self.chiudi = QPushButton(L("Chiudi", "Close"))
        self.chiudi.clicked.connect(self.accept)
        v.addWidget(self.chiudi)
        self._timer = QTimer(self)
        self._timer.timeout.connect(self._giro)
        self._timer.start(400)
        self._giro()

    def _giro(self):
        try:
            dimensione = os.path.getsize(TX_LOG)
        except OSError:
            dimensione = 0
        # ⚠️ Il registro viene azzerato all'inizio di ogni transazione: se e'
        # piu' corto di dove eravamo arrivati, e' una transazione NUOVA e si
        # riparte da capo. Senza questo controllo non si vedrebbe piu' niente.
        if dimensione < self._pos:
            self._pos = 0
            self.log.clear()
        if dimensione > self._pos:
            try:
                with open(TX_LOG, "rb") as f:
                    f.seek(self._pos)
                    dati = f.read()
                    self._pos = f.tell()
            except OSError:
                dati = b""
            if dati:
                testo = dati.decode("utf-8", "replace").rstrip("\n")
                if testo:
                    self.log.appendPlainText(testo)
                    m = None
                    for m in re.finditer(r'(\d{1,3})%', testo):
                        pass
                    if m:
                        v = int(m.group(1))
                        if 0 <= v <= 100:
                            self.bar.setRange(0, 100)
                            self.bar.setValue(v)
        st = tx_stato()
        if st.get("stato") == "finita":
            self._timer.stop()
            self.bar.setRange(0, 100)
            self.bar.setValue(100)
            rc = st.get("rc")
            self.log.appendPlainText(
                "\n" + (L("✓ Completato.", "✓ Done.") if rc in (0, "0")
                        else L("! Non riuscito (rc=%s)", "! Failed (rc=%s)") % rc))
            if self._alla_fine:
                self._alla_fine(rc)
                self._alla_fine = None

# ============================ SMALL WIDGETS ============================
class ToggleSwitch(QAbstractButton):
    W, H = 46, 24          # widget size
    TY, TH = 3, 18         # track y / height  -> track spans 3..21, centre y = 12
    KD, KM = 14, 5         # knob diameter / side margin -> knob y = 5..19, centre y = 12 (centred)
    def __init__(self, checked=False):
        super().__init__(); self.setCheckable(True); self.setChecked(checked)
        self.setFixedSize(self.W, self.H); self.setCursor(Qt.CursorShape.PointingHandCursor)
    def paintEvent(self, _e):
        p = QPainter(self)
        try:
            p.setRenderHint(QPainter.RenderHint.Antialiasing)
            on = self.isChecked()
            track = QColor("#d8a849") if on else QColor(74, 64, 46)   # brass when on
            p.setPen(Qt.PenStyle.NoPen); p.setBrush(track)
            p.drawRoundedRect(0, self.TY, self.W, self.TH, self.TH / 2, self.TH / 2)
            p.setBrush(QColor("#241b0f") if on else QColor("#c9b89a"))  # dark knob on brass
            ky = self.TY + (self.TH - self.KD) / 2.0                    # vertically centred in the track
            kx = (self.W - self.KD - self.KM) if on else self.KM
            p.drawEllipse(QRectF(kx, ky, self.KD, self.KD))
        finally:
            if p.isActive(): p.end()

def star_widget(rating, n=0, size=13):
    w = QWidget(); h = QHBoxLayout(w); h.setContentsMargins(0, 0, 0, 0); h.setSpacing(1)
    full = int(round(rating))
    lab = QLabel(("★" * full) + ("☆" * (5 - full)))
    lab.setStyleSheet("color:#e8c878;font-size:%dpx;background:transparent;" % size); h.addWidget(lab)
    if n:
        c = QLabel("(%d)" % n); c.setStyleSheet("color:#b9a07a;font-size:%dpx;background:transparent;" % (size - 1)); h.addWidget(c)
    h.addStretch(1); return w

def badge(text, color="#b9a07a"):
    l = QLabel(text); l.setStyleSheet("background:rgba(216,168,73,0.14);color:%s;border-radius:7px;padding:1px 7px;font-size:11px;" % color)
    l.setMaximumHeight(20); return l

# Applicazioni nostre il cui PACCHETTO si chiama diversamente dall'icona.
# ⚠️ Solo applicazioni: i pacchetti di servizio tengono l'icona generica,
# perche' non sono applicazioni e fingere il contrario confonde e basta.
ICONE_NOSTRE = {
    "skillfish-ai-panel": "skillfish-ai",
    "skillfish-kernel-manager": "skillfish-kernel",
    "skillfish-dashboard": "skillfish-remote",
    "skillfish-tuner": "skillfish-tuner",
    "skillfish-fan": "skillfish-fan",
    "skillfish-monitor": "skillfish-monitor",
    "skillfish-snapshots": "skillfish-snapshots",
    "skillfish-hub": "skillfish-hub",
    "skillfish-emulators": "skillfish-emulators",
}


def _icona_esatta(nome):
    """Esiste un'icona che si chiama PROPRIO cosi'?

    ⚠️ QIcon.fromTheme() non basta: la specifica dei nomi ricade sul prefisso, e
    «skillfish-base» — che un'icona sua non ce l'ha — tornerebbe «skillfish»,
    cioe' il pesce. Cosi' ogni nostro pacchetto di servizio si prendeva il
    marchio del sistema: sei riquadri col pesce e nessuno che dicesse cosa fa
    quel pacchetto. I pacchetti che non sono applicazioni tengono l'icona
    generica, che e' la verita': non sono applicazioni.
    """
    if not nome or "/" in nome:
        return None
    for base in ("/usr/share/icons", os.path.expanduser("~/.local/share/icons")):
        for tema in ("SkillFishSteampunk", "hicolor", "breeze-dark", "breeze"):
            for misura in ("scalable", "256x256", "128x128", "64x64", "48x48"):
                for est in (".svg", ".png"):
                    p = os.path.join(base, tema, misura, "apps", nome + est)
                    if os.path.exists(p):
                        return p
    for est in (".png", ".svg", ".xpm"):
        p = "/usr/share/pixmaps/" + nome + est
        if os.path.exists(p):
            return p
    return None


def pixmap_for(app, size=48):
    if app.icon and os.path.exists(app.icon):
        pm = QPixmap(app.icon)
        if not pm.isNull():
            return pm.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
    # Un pacchetto senza scheda AppStream non porta un'icona. Se pero' ne
    # installa una col proprio nome — skillfish-snapshots, skillfish-fan e le
    # altre applicazioni nostre — quella e' la sua, ed e' meglio di un riquadro
    # grigio uguale per tutti.
    pkg = (getattr(app, "pkgid", "") or "").strip()
    for nome in (ICONE_NOSTRE.get(pkg, ""),
                 getattr(app, "appid", "") or "", pkg):
        nome = nome.replace(".desktop", "").strip()
        percorso = _icona_esatta(nome)
        if percorso:
            pm = QPixmap(percorso)
            if not pm.isNull():
                return pm.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
    ic = QIcon.fromTheme({"flatpak": "flatpak", "snap": "snap-store"}.get(app.backend, "application-x-executable"))
    if not ic.isNull(): return ic.pixmap(size, size)
    pm = QPixmap(ICON)
    return pm.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) if not pm.isNull() else QPixmap()

def human(n):
    n = float(n or 0)
    for u in ("B", "KB", "MB", "GB"):
        if n < 1024: return "%.0f %s" % (n, u) if u != "B" else "%d B" % n
        n /= 1024
    return "%.1f TB" % n


class FlowLayout(QLayout):
    """A simple reflowing layout for a responsive grid of cards."""
    def __init__(self, spacing=14):
        super().__init__(); self._items = []; self._sp = spacing
        self.setContentsMargins(0, 0, 0, 0)
    def addItem(self, it): self._items.append(it)
    def count(self): return len(self._items)
    def itemAt(self, i): return self._items[i] if 0 <= i < len(self._items) else None
    def takeAt(self, i): return self._items.pop(i) if 0 <= i < len(self._items) else None
    def expandingDirections(self): return Qt.Orientation(0)
    def hasHeightForWidth(self): return True
    def heightForWidth(self, w): return self._arrange(QRect(0, 0, w, 0), True)
    def setGeometry(self, r): super().setGeometry(r); self._arrange(r, False)
    def sizeHint(self): return self.minimumSize()
    def minimumSize(self):
        s = QSize()
        for it in self._items: s = s.expandedTo(it.minimumSize())
        return s
    def _arrange(self, rect, test):
        x = rect.x(); y = rect.y(); line = 0
        for it in self._items:
            sz = it.sizeHint(); w = sz.width(); h = sz.height()
            if x + w > rect.right() + 1 and line > 0:
                x = rect.x(); y += line + self._sp; line = 0
            if not test: it.setGeometry(QRect(QPoint(int(x), int(y)), sz))
            x += w + self._sp; line = max(line, h)
        return int(y + line - rect.y())


def src_label(app):
    if app.backend == "apt" and app.origin == AETHERIUM: return "SkillFishOS"
    return SRC_LABEL.get(app.backend, app.backend)

def _lazy_icon(card, app):
    url = getattr(app, "_snap_icon_url", None)
    if url and not app.icon:
        card._il = ImgLoad([url])
        card._il.ready.connect(lambda _i, pm: card.ic.setPixmap(
            pm.scaled(card.ic.width(), card.ic.height(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)))
        card._il.start()


class GridCard(QFrame):
    """Compact tile for the home grid (icon + name + summary + rating)."""
    def __init__(self, app, on_open, on_install=None):
        super().__init__(); self.app = app; self.on_open = on_open
        self.setFixedSize(348, 98); self.setCursor(Qt.CursorShape.PointingHandCursor)
        self.setStyleSheet("QFrame{background:#171206;border:1px solid rgba(216,168,73,0.16);border-radius:14px;}"
                           "QFrame:hover{border-color:rgba(216,168,73,0.55);background:#1d1609;}"
                           "QLabel{background:transparent;border:none;}")
        h = QHBoxLayout(self); h.setContentsMargins(14, 12, 14, 12); h.setSpacing(12)
        self.ic = QLabel(); self.ic.setStyleSheet("background:transparent;"); self.ic.setPixmap(pixmap_for(app, 56)); self.ic.setFixedSize(56, 56); h.addWidget(self.ic)
        col = QVBoxLayout(); col.setSpacing(2)
        nm = QLabel(app.name); nm.setStyleSheet("font-weight:700;color:#f1e3c6;font-size:15px;background:transparent;"); col.addWidget(nm)
        sm = QLabel((app.summary or "").strip()); sm.setStyleSheet("color:#a8966f;font-size:12px;background:transparent;"); sm.setWordWrap(True); sm.setMaximumHeight(34)
        sm.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred); col.addWidget(sm)
        bot = QHBoxLayout(); bot.setSpacing(6)
        if app.rating_n: bot.addWidget(star_widget(app.rating, app.rating_n, 12))
        bot.addStretch(1); bot.addWidget(badge(src_label(app))); col.addLayout(bot)
        h.addLayout(col, 1); _lazy_icon(self, app)
    def mouseReleaseEvent(self, e):
        if e.button() == Qt.MouseButton.LeftButton: self.on_open(self.app)


def install_btn(app, on_install):
    b = QPushButton(L("Installa", "Install")); b.setCursor(Qt.CursorShape.PointingHandCursor); b.setFixedWidth(108)
    b.setStyleSheet("QPushButton{background:rgba(156,207,106,0.14);color:#9ccf6a;border:1px solid rgba(156,207,106,0.45);border-radius:9px;padding:6px 8px;font-weight:700;}QPushButton:hover{background:#9ccf6a;color:#10240a;}")
    b.clicked.connect(lambda: on_install(app)); return b

def remove_btn(app, on_remove):
    b = QPushButton(L("Rimuovi", "Remove")); b.setCursor(Qt.CursorShape.PointingHandCursor); b.setFixedWidth(108)
    b.setStyleSheet("QPushButton{background:rgba(207,138,106,0.12);color:#e0b0a0;border:1px solid rgba(207,138,106,0.45);border-radius:9px;padding:6px 8px;font-weight:700;}QPushButton:hover{background:#cf6a4a;color:#fff;}")
    b.clicked.connect(lambda: on_remove(app)); return b


class ListCard(QFrame):
    """Full-width card for category/search/installed lists (Discover style)."""
    def __init__(self, app, on_open, on_install=None, on_remove=None):
        super().__init__(); self.app = app; self.on_open = on_open
        self.setObjectName("card")
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        self.setMinimumHeight(96); self.setCursor(Qt.CursorShape.PointingHandCursor)
        self.setStyleSheet("QFrame#card{background:#171206;border:1px solid rgba(216,168,73,0.16);border-radius:14px;}"
                           "QFrame#card:hover{border-color:rgba(216,168,73,0.55);background:#1d1609;}"
                           "QLabel{background:transparent;border:none;}")
        h = QHBoxLayout(self); h.setContentsMargins(16, 10, 14, 10); h.setSpacing(13)
        self.ic = QLabel(); self.ic.setStyleSheet("background:transparent;"); self.ic.setPixmap(pixmap_for(app, 56)); self.ic.setFixedSize(56, 56); h.addWidget(self.ic)
        col = QVBoxLayout(); col.setSpacing(2)
        r1 = QHBoxLayout(); r1.setSpacing(8)
        nm = QLabel(app.name); nm.setStyleSheet("font-weight:700;color:#f1e3c6;font-size:15px;background:transparent;"); r1.addWidget(nm)
        r1.addWidget(badge(src_label(app)))
        r1.addStretch(1)
        if app.rating_n:
            full = int(round(app.rating))
            st = QLabel(("★" * full + "☆" * (5 - full)) + "  %d" % app.rating_n)
            st.setStyleSheet("color:#e8c878;font-size:12px;background:transparent;"); r1.addWidget(st)
        col.addLayout(r1)
        sm = QLabel((app.summary or "").strip()); sm.setStyleSheet("color:#9b8a63;font-size:13px;background:transparent;")
        sm.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)  # don't let long text widen the card
        col.addWidget(sm)
        h.addLayout(col, 1)
        if app.installed and on_remove is not None:
            h.addWidget(remove_btn(app, on_remove), 0, Qt.AlignmentFlag.AlignVCenter)
        elif (not app.installed) and on_install is not None:
            h.addWidget(install_btn(app, on_install), 0, Qt.AlignmentFlag.AlignVCenter)
        _lazy_icon(self, app)
    def mouseReleaseEvent(self, e):
        if e.button() == Qt.MouseButton.LeftButton: self.on_open(self.app)


class Carousel(QWidget):
    """Discover-style screenshot carousel: one big image with prev/next + dots."""
    def __init__(self, urls, height=400):
        super().__init__()
        self.urls = list(urls); self.idx = 0; self.pms = {}; self.H = height
        v = QVBoxLayout(self); v.setContentsMargins(0, 0, 0, 0); v.setSpacing(8)
        row = QHBoxLayout(); row.setSpacing(8)
        arrow = ("QToolButton{color:#e8c878;background:rgba(216,168,73,0.12);border:none;border-radius:20px;"
                 "font-size:24px;min-width:40px;min-height:40px;}QToolButton:hover{background:rgba(216,168,73,0.30);}")
        self.b_prev = QToolButton(); self.b_prev.setText("‹"); self.b_prev.setCursor(Qt.CursorShape.PointingHandCursor); self.b_prev.setStyleSheet(arrow); self.b_prev.clicked.connect(self.prev)
        self.img = QLabel(); self.img.setFixedHeight(height); self.img.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.img.setStyleSheet("background:#100c06;border:1px solid rgba(216,168,73,0.16);border-radius:12px;color:#8a7a55;")
        self.img.setText(L("Caricamento screenshot…", "Loading screenshots…"))
        self.b_next = QToolButton(); self.b_next.setText("›"); self.b_next.setCursor(Qt.CursorShape.PointingHandCursor); self.b_next.setStyleSheet(arrow); self.b_next.clicked.connect(self.next)
        row.addWidget(self.b_prev); row.addWidget(self.img, 1); row.addWidget(self.b_next); v.addLayout(row)
        self.dots = QLabel(); self.dots.setAlignment(Qt.AlignmentFlag.AlignCenter); self.dots.setStyleSheet("color:#d8a849;font-size:15px;letter-spacing:4px;background:transparent;"); v.addWidget(self.dots)
        self._ld = ImgLoad(self.urls); self._ld.ready.connect(self._got); self._ld.start()
        self._update()
    def _got(self, i, pm):
        self.pms[i] = pm
        if i == self.idx: self._update()
        else: self._dots()
    def prev(self): self.idx = (self.idx - 1) % max(1, len(self.urls)); self._update()
    def next(self): self.idx = (self.idx + 1) % max(1, len(self.urls)); self._update()
    def _update(self):
        pm = self.pms.get(self.idx)
        if pm and not pm.isNull(): self.img.setPixmap(pm.scaledToHeight(self.H, Qt.TransformationMode.SmoothTransformation))
        single = len(self.urls) <= 1
        self.b_prev.setVisible(not single); self.b_next.setVisible(not single); self._dots()
    def _dots(self):
        self.dots.setText("" if len(self.urls) <= 1 else "  ".join("●" if i == self.idx else "○" for i in range(len(self.urls))))


# ======================= QUELLO CHE CI HANNO CHIESTO DI APRIRE =======================
# ⚠️ Discover era il gestore di .deb, .flatpakref, .flatpakrepo e dei link
# appstream:// e apt://. Togliendolo senza mettere questo, un doppio clic su un
# pacchetto scaricato o su un pulsante «installa» di Flathub non aprirebbe piu'
# niente — e non e' un errore che l'utente riesce a collegare a noi: gli si
# rompe il doppio clic, e basta.
#
# ⚠️ QUI NON SI INSTALLA NIENTE. Si capisce COSA ci hanno passato e lo si
# racconta all'utente; a installare e' la solita transazione staccata, dopo un
# si' esplicito. Un file arrivato da fuori che si installa da solo perche' ci
# hanno cliccato sopra e' esattamente il modo in cui si prende un pacchetto
# malevolo.
BERSAGLI = {
    ".deb": "deb",
    ".flatpakref": "flatpakref",
    ".flatpakrepo": "flatpakrepo",
    ".flatpak": "flatpakbundle",
}


def bersaglio(argomenti):
    u"""(tipo, valore) di cio' che ci hanno passato, o None.

    Accetta un percorso, un file:// (KDE passa quello) e i due schemi che
    gestiva Discover. ⚠️ Il file deve esistere ed essere un file normale: un
    percorso inventato, una cartella o un dispositivo non devono arrivare
    all'helper, che gira da root.
    """
    for a in (argomenti or []):
        if not a or a.startswith("-"):
            continue
        if a.startswith("appstream://"):
            return ("app", a[len("appstream://"):].strip("/ "))
        if a.startswith("apt://"):
            return ("app", a[len("apt://"):].strip("/ "))
        percorso = a
        if percorso.startswith("file://"):
            percorso = urllib.parse.unquote(urllib.parse.urlparse(percorso).path)
        percorso = os.path.abspath(os.path.expanduser(percorso))
        tipo = BERSAGLI.get(os.path.splitext(percorso)[1].lower())
        if tipo and os.path.isfile(percorso):
            return (tipo, percorso)
    return None


def _ini(percorso):
    u"""Le chiavi di un .flatpakref o .flatpakrepo, che sono file ini."""
    fuori = {}
    try:
        with open(percorso, encoding="utf-8", errors="replace") as f:
            for riga in f:
                if "=" in riga and not riga.lstrip().startswith(("#", "[")):
                    k, v = riga.split("=", 1)
                    fuori[k.strip()] = v.strip()
    except OSError:
        pass
    return fuori


def descrivi(tipo, percorso):
    u"""Che cosa sto per installare: nome, versione, provenienza.

    Serve perche' la domanda «vuoi installarlo?» abbia una risposta possibile.
    Chiedere su un nome di file non e' chiedere: e' far premere Si'.
    """
    nome = os.path.basename(percorso)
    righe = []
    if tipo == "deb":
        rc, out, _ = run(["dpkg-deb", "-W", "--showformat",
                          "${Package}\\n${Version}\\n${Architecture}\\n${Installed-Size}\\n${Maintainer}",
                          percorso], 20)
        if rc == 0:
            campi = (out or "").split("\n")
            while len(campi) < 5:
                campi.append("")
            nome = campi[0] or nome
            righe = [
                (L("Versione", "Version"), campi[1]),
                (L("Architettura", "Architecture"), campi[2]),
                (L("Spazio occupato", "Installed size"),
                 ("%s kB" % campi[3]) if campi[3].isdigit() else campi[3]),
                (L("Chi lo mantiene", "Maintainer"), campi[4]),
            ]
    elif tipo in ("flatpakref", "flatpakrepo"):
        d = _ini(percorso)
        nome = d.get("Name") or d.get("Title") or nome
        righe = [(L("Origine", "Origin"), d.get("Url") or d.get("RuntimeRepo") or "—")]
        if d.get("Title"):
            righe.insert(0, (L("Repository", "Repository"), d["Title"]))
    elif tipo == "flatpakbundle":
        try:
            righe = [(L("Dimensione", "Size"),
                      "%.1f MiB" % (os.path.getsize(percorso) / 1048576.0))]
        except OSError:
            pass
    return nome, righe


def avviso_bersaglio(tipo):
    u"""⚠️ Un file preso da fuori non e' un pacchetto dei nostri repository.

    Nessuna delle nostre chiavi lo ha firmato: chi lo installa si fida di chi
    glielo ha dato. Dirlo non e' burocrazia — e' l'unica differenza che conta
    fra installare dall'Hub e installare un file trovato in giro, e l'utente
    non ha modo di saperlo se non glielo diciamo qui.
    """
    if tipo == "flatpakrepo":
        return L("Stai aggiungendo una fonte di software nuova. Da quel momento "
                 "quello che arriva da lì viene installato come il resto: "
                 "aggiungila solo se sai di chi è.",
                 "You are adding a new source of software. From then on what comes "
                 "from it is installed like everything else: add it only if you "
                 "know whose it is.")
    return L("Questo file non viene dai nostri repository e nessuna delle nostre "
             "chiavi lo ha firmato: installandolo ti fidi di chi te lo ha dato.",
             "This file does not come from our repositories and none of our keys "
             "signed it: by installing it you are trusting whoever gave it to you.")


POLKIT_DIR = "/usr/share/polkit-1/actions"


def permessi_apt(pkgid, appid=""):
    u"""Le autorizzazioni che questo pacchetto dichiara a polkit.

    ⚠️ Si guarda quali file di politica il PACCHETTO installa, non quali
    contengono il suo nome: e' l'unico legame vero fra un .deb e le
    autorizzazioni che chiede, e non si sbaglia con i nomi che si somigliano.
    """
    if not pkgid:
        return ""
    rc, out, _ = run(["dpkg", "-L", pkgid], 12)
    if rc != 0:
        return ""
    politiche = [r.strip() for r in out.splitlines()
                 if r.strip().startswith(POLKIT_DIR) and r.strip().endswith(".policy")]
    if not politiche:
        return ""
    fuori = []
    for p in politiche:
        try:
            with open(p, encoding="utf-8", errors="replace") as f:
                testo = f.read()
        except OSError:
            continue
        for azione in re.findall(r'<action id="[^"]*">(.*?)</action>', testo, re.S):
            # la descrizione nella lingua dell'utente, se c'e'
            d = (re.search(r'<description xml:lang="%s">(.*?)</description>' % LANG, azione, re.S)
                 or re.search(r'<description>(.*?)</description>', azione, re.S))
            if not d:
                continue
            regola = re.search(r'<allow_active>(.*?)</allow_active>', azione)
            regola = (regola.group(1).strip() if regola else "")
            come = {
                "yes": L("senza chiedere niente", "without asking"),
                "auth_admin": L("chiede la password ogni volta", "asks for the password every time"),
                "auth_admin_keep": L("chiede la password una volta per sessione",
                                     "asks for the password once per session"),
                "auth_self": L("chiede la tua password", "asks for your password"),
                "auth_self_keep": L("chiede la tua password una volta per sessione",
                                    "asks for your password once per session"),
                "no": L("non e' permesso", "not allowed"),
            }.get(regola, regola)
            testo_d = re.sub(r'\s+', " ", d.group(1)).strip()
            fuori.append("• %s — %s" % (testo_d, come) if come else "• " + testo_d)
    return "\n".join(fuori)

# ============================ MAIN WINDOW ============================
class Hub(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("SkillFishOS Hub — Software")
        self.setWindowIcon(QIcon(ICON_APP)); self.resize(1080, 740)
        self.cat = None; self._op = None; self._hist = []
        self._subcat = None; self._last_cat = None; self._active_cat = None
        self.cfg = load_cfg()
        self.cfg.setdefault("flatpak", have("flatpak")); self.cfg.setdefault("snap", have("snap"))
        self.cfg.setdefault("sort", "name"); self.cfg.setdefault("filter", "all")
        # ⚠️ Aperti su un file (.deb, .flatpakref…) o su un link
        # appstream://: era il mestiere di Discover, e senza questo un
        # doppio clic su un pacchetto scaricato non aprirebbe piu' niente.
        self._bersaglio = bersaglio(sys.argv[1:])
        self._app_da_aprire = None
        QTimer.singleShot(400, self.apri_bersaglio)
        # se una transazione sta gia' girando (l'Hub magari e' morto
        # proprio perche' l'aggiornamento gli ha cambiato le Qt sotto),
        # ci si riattacca invece di far finta di niente
        QTimer.singleShot(800, self._riattacca_transazione)

        root = QWidget(); self.setCentralWidget(root)
        outer = QHBoxLayout(root); outer.setContentsMargins(0, 0, 0, 0); outer.setSpacing(0)
        # sidebar
        side = QFrame(); side.setFixedWidth(240); side.setStyleSheet("QFrame{background:#0f0c07;border-right:1px solid rgba(216,168,73,0.18);}")
        sv = QVBoxLayout(side); sv.setContentsMargins(0, 0, 0, 0); sv.setSpacing(0)
        logow = QWidget(); logo = QHBoxLayout(logow); logo.setContentsMargins(16, 16, 16, 8); lpm = QLabel(); pm = QPixmap(ICON)
        if not pm.isNull(): lpm.setPixmap(pm.scaled(30, 30, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
        logo.addWidget(lpm); lt = QLabel("Hub"); lt.setStyleSheet("font-size:19px;font-weight:800;color:#e8c878;"); logo.addWidget(lt); logo.addStretch(1)
        sv.addWidget(logow)
        navsc = QScrollArea(); navsc.setWidgetResizable(True); navsc.setFrameShape(QFrame.Shape.NoFrame)
        navsc.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        navsc.setStyleSheet("QScrollArea{background:transparent;} QScrollBar:vertical{width:8px;background:transparent;}")
        navhost = QWidget(); navhost.setStyleSheet("background:transparent;")
        nv = QVBoxLayout(navhost); nv.setContentsMargins(10, 4, 8, 14); nv.setSpacing(2)
        self.navgrp = QButtonGroup(self); self.nav = {}
        self.NAVSTYLE = ("QPushButton{color:#cdbb95;text-align:left;padding:9px 12px;border:none;border-radius:8px;font-size:16px;}"
                         "QPushButton:hover{background:rgba(216,168,73,0.10);}"
                         "QPushButton:checked{background:rgba(216,168,73,0.20);color:#f1e3c6;font-weight:700;}")
        self.SUBSTYLE = ("QPushButton{color:#b6a378;text-align:left;padding:6px 10px 6px 32px;border:none;border-radius:7px;font-size:14px;}"
                         "QPushButton:hover{background:rgba(216,168,73,0.10);}"
                         "QPushButton:checked{background:rgba(216,168,73,0.16);color:#f1e3c6;font-weight:700;}")
        def navbtn(text, cb):
            b = QPushButton(text); b.setCheckable(True)
            b.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
            b.setStyleSheet(self.NAVSTYLE); b.clicked.connect(cb); self.navgrp.addButton(b); nv.addWidget(b); return b
        for key, label, emoji in [("home", L("Esplora", "Explore"), "✦"), ("installed", L("Installati", "Installed"), "✓"),
                                  ("updates", L("Aggiornamenti", "Updates"), "⟳"), ("sources", L("Sorgenti", "Sources"), "⚙")]:
            self.nav[key] = navbtn("  %s   %s" % (emoji, label), lambda _c, k=key: self.go(k))
        # Spenti finche' il catalogo non c'e'. Un pulsante che si puo'
        # premere e non fa niente e' quello che ha invitato il clic di #73.
        for _b in self.nav.values():
            _b.setEnabled(False)
        sep = QLabel(L("CATEGORIE", "CATEGORIES")); sep.setStyleSheet("color:#8a7a55;font-size:11px;font-weight:700;letter-spacing:1px;padding:12px 12px 4px;"); nv.addWidget(sep)
        self.catbox = QWidget(); self.catbox.setStyleSheet("background:transparent;")
        self.catlay = QVBoxLayout(self.catbox); self.catlay.setContentsMargins(0, 0, 0, 0); self.catlay.setSpacing(2)
        nv.addWidget(self.catbox); nv.addStretch(1)
        self.catbtns = {}; self.subbtns = {}
        navsc.setWidget(navhost); sv.addWidget(navsc, 1); outer.addWidget(side)
        self._build_categories()
        # right
        right = QVBoxLayout(); right.setContentsMargins(0, 0, 0, 0); right.setSpacing(0)
        top = QHBoxLayout(); top.setContentsMargins(16, 12, 16, 10)
        self.back = QToolButton(); self.back.setText("←"); self.back.clicked.connect(self.go_back)
        self.back.setStyleSheet("QToolButton{color:#e8c878;font-size:18px;border:none;padding:2px 8px;}"); top.addWidget(self.back)
        self.search = QLineEdit(); self.search.setPlaceholderText(L("Cerca…", "Search…"))
        self.search.setFixedWidth(280)
        self.search.setStyleSheet("QLineEdit{background:#171206;border:1px solid rgba(216,168,73,0.25);border-radius:9px;padding:6px 10px;color:#f1e3c6;font-size:13px;}")
        self.search.returnPressed.connect(self.do_search); top.addWidget(self.search)
        self.refresh = QToolButton(); self.refresh.setText("⟳"); self.refresh.setToolTip(L("Aggiorna elenchi e controlla aggiornamenti", "Refresh lists and check for updates"))
        self.refresh.setCursor(Qt.CursorShape.PointingHandCursor)
        self.refresh.setStyleSheet("QToolButton{color:#e8c878;font-size:18px;border:1px solid rgba(216,168,73,0.25);border-radius:9px;padding:3px 9px;}QToolButton:hover{background:rgba(216,168,73,0.12);}")
        self.refresh.clicked.connect(self.manual_refresh); top.addWidget(self.refresh)
        self.refresh.setEnabled(False)   # acceso in on_loaded, vedi #73
        top.addStretch(1)
        self.title = QLabel(""); self.title.setStyleSheet("color:#e8c878;font-weight:700;font-size:15px;"); top.addWidget(self.title)
        right.addLayout(top)
        self.stack = QStackedWidget(); right.addWidget(self.stack, 1)
        rw = QWidget(); rw.setLayout(right); rw.setStyleSheet("background:#0b0905;"); outer.addWidget(rw, 1)
        # panes
        self.p_loading = self._loading_pane()
        self.p_scroll = QScrollArea(); self.p_scroll.setWidgetResizable(True); self.p_scroll.setFrameShape(QFrame.Shape.NoFrame)
        self.p_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        # ⚠️ LA BARRA VERTICALE RESTA SEMPRE, ANCHE QUANDO NON SERVE.
        # Le schede sono in un layout che manda a capo da solo: quando la
        # barra compare la larghezza cala, le schede si riordinano, l'altezza
        # scende sotto la finestra, la barra sparisce, la larghezza torna...
        # e da fuori si vede tutto che lampeggia. Riservandole lo spazio, la
        # larghezza non cambia piu' e l'oscillazione non puo' innescarsi.
        self.p_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
        self.p_scroll.setStyleSheet("QScrollArea{background:transparent;}"
                                    "QScrollBar:vertical{width:10px;background:transparent;}")
        self.p_host = QWidget(); self.p_lay = QVBoxLayout(self.p_host); self.p_lay.setContentsMargins(20, 10, 20, 18); self.p_lay.setSpacing(10); self.p_scroll.setWidget(self.p_host)
        self.p_detail = QScrollArea(); self.p_detail.setWidgetResizable(True); self.p_detail.setFrameShape(QFrame.Shape.NoFrame)
        for w in (self.p_loading, self.p_scroll, self.p_detail): self.stack.addWidget(w)
        self.statusBar(); self.stack.setCurrentWidget(self.p_loading)
        self.loader = Loader(); self.loader.msg.connect(self.statusBar().showMessage)
        self.loader.done.connect(self.on_loaded); self.loader.start()

    def _mk_btn(self, text, cb, sub=False):
        b = QPushButton(text); b.setCheckable(True)
        b.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        b.setStyleSheet(self.SUBSTYLE if sub else self.NAVSTYLE)
        b.clicked.connect(cb); self.navgrp.addButton(b); return b

    def _build_categories(self):
        for b in list(self.catbtns.values()) + list(self.subbtns.values()):
            self.navgrp.removeButton(b)
        while self.catlay.count():
            it = self.catlay.takeAt(0); w = it.widget()
            if w is not None: w.setParent(None)
        self.catbtns = {}; self.subbtns = {}
        active = getattr(self, "_active_cat", None)
        for k, name, emoji, _m, _s in CATEGORIES:
            caret = "▾" if k == active else "▸"
            b = self._mk_btn("%s  %s   %s" % (caret, emoji, name), lambda _c, kk=k: self._click_category(kk))
            self.catbtns[k] = b; self.catlay.addWidget(b)
            if k == active and self.cat:
                present = set()
                for a in self.cat.by_cat.get(k, []):
                    for c in (a.cats or []): present.add(c)
                # "All <category>" entry shows the full category (no subcategory filter)
                allb = self._mk_btn(L("Tutte", "All"), lambda _c, kk=k: self._set_subcat(None), sub=True)
                self.subbtns["__all__"] = allb; self.catlay.addWidget(allb)
                for sc, nm in SUBCATS.get(k, []):
                    if sc not in present: continue
                    sb = self._mk_btn(nm, lambda _c, s=sc: self._set_subcat(s), sub=True)
                    self.subbtns[sc] = sb; self.catlay.addWidget(sb)
        if active and active in self.catbtns:
            # keep the expanded parent visually highlighted even when a subcategory is selected
            self.catbtns[active].setStyleSheet(self.NAVSTYLE + "QPushButton{background:rgba(216,168,73,0.09);}")
        if active:
            if self._subcat and self._subcat in self.subbtns: self.subbtns[self._subcat].setChecked(True)
            elif "__all__" in self.subbtns: self.subbtns["__all__"].setChecked(True)

    def resizeEvent(self, e):
        super().resizeEvent(e)
        self._posiziona_riquadro_app()
        if not getattr(self, "cat", None): return
        if not hasattr(self, "_rstimer"):
            self._rstimer = QTimer(self); self._rstimer.setSingleShot(True)
            self._rstimer.timeout.connect(self._on_resize_settled)
        self._rstimer.start(220)

    def _on_resize_settled(self):
        cur = self._hist[-1] if self._hist else ("home",)
        if cur and cur[0] == "home":
            self.show_home()  # re-flow the tile grid for the new width

    def _loading_pane(self):
        w = QWidget(); v = QVBoxLayout(w); v.addStretch(1)
        l = QLabel(L("Carico il catalogo software…", "Loading the software catalogue…")); l.setAlignment(Qt.AlignmentFlag.AlignCenter)
        l.setStyleSheet("color:#b9a07a;font-size:16px;"); bar = QProgressBar(); bar.setRange(0, 0); bar.setFixedWidth(280); bar.setTextVisible(False)
        v.addWidget(l); hb = QHBoxLayout(); hb.addStretch(1); hb.addWidget(bar); hb.addStretch(1); v.addLayout(hb); v.addStretch(1); return w

    def on_loaded(self, cat):
        self._snap_fatte = set()
        self.cat = cat; self.statusBar().clearMessage()
        # Il catalogo c'e': adesso il menu' puo' rispondere.
        for _b in self.nav.values():
            _b.setEnabled(True)
        self.refresh.setEnabled(True)
        self.refresh_updates_badge(); self.go("home")
        chiesta = getattr(self, "_app_da_aprire", None)
        if chiesta:
            self._app_da_aprire = None
            a = next((x for x in self.cat.apps.values()
                      if x.pkgid == chiesta or x.appid == chiesta), None)
            if a:
                self.open_app(a)
            else:
                # non trovata per nome esatto: meglio la ricerca che una
                # finestra vuota senza spiegazioni
                self.search.setText(chiesta)
                self.do_search()
            return
        view = os.environ.get("SFX_HUB_VIEW")
        if view:
            if view.startswith("cat:"): self.show_category(view[4:])
            elif view.startswith("app:"):
                a = next((x for x in self.cat.apps.values() if x.pkgid == view[4:] or x.appid == view[4:]), None)
                if a: self.open_app(a)
            else: self.go(view)

    def apri_bersaglio(self):
        u"""Il file (o il link) con cui ci hanno aperti.

        ⚠️ Si chiede prima, sempre, e si mostra COSA: nome, versione, chi lo
        mantiene, da dove viene. Un pacchetto che parte da solo perche' l'utente
        ci ha fatto doppio clic sopra e' il modo classico di installare qualcosa
        che non si voleva.
        """
        b = getattr(self, "_bersaglio", None)
        if not b:
            return
        tipo, valore = b
        self._bersaglio = None
        if tipo == "app":
            # l'app la si apre quando il catalogo c'e': ci pensa on_loaded
            self._app_da_aprire = valore
            return
        nome, righe = descrivi(tipo, valore)
        titoli = {
            "deb": L("Installare questo pacchetto?", "Install this package?"),
            "flatpakref": L("Installare questa applicazione Flatpak?",
                            "Install this Flatpak application?"),
            "flatpakrepo": L("Aggiungere questo repository Flatpak?",
                             "Add this Flatpak repository?"),
            "flatpakbundle": L("Installare questo pacchetto Flatpak?",
                               "Install this Flatpak bundle?"),
        }
        dlg = QDialog(self)
        dlg.setWindowTitle("SkillFishOS Hub")
        dlg.setMinimumWidth(520)
        v = QVBoxLayout(dlg)
        t = QLabel(titoli.get(tipo, L("Installare?", "Install?")))
        t.setStyleSheet("color:#e8c878;font-size:16px;font-weight:700;")
        v.addWidget(t)
        n = QLabel(nome)
        n.setStyleSheet("color:#f1e3c6;font-size:15px;font-weight:600;")
        v.addWidget(n)
        for etichetta, testo in righe:
            if not testo:
                continue
            r = QLabel("%s: %s" % (etichetta, testo))
            r.setWordWrap(True)
            r.setStyleSheet("color:#b9a07a;font-size:12px;")
            v.addWidget(r)
        p = QLabel(valore)
        p.setWordWrap(True)
        p.setStyleSheet("color:#8a6a2a;font-size:11px;")
        v.addWidget(p)
        av = QLabel(avviso_bersaglio(tipo))
        av.setWordWrap(True)
        av.setStyleSheet("color:#d8a849;font-size:12px;margin-top:6px;")
        v.addWidget(av)
        pulsanti = QHBoxLayout()
        pulsanti.addStretch(1)
        annulla = QPushButton(L("Annulla", "Cancel"))
        annulla.setStyleSheet("QPushButton{background:rgba(216,168,73,0.18);color:#f1e3c6;border-radius:9px;padding:7px 16px;}")
        annulla.clicked.connect(dlg.reject)
        pulsanti.addWidget(annulla)
        ok = QPushButton(L("Installa", "Install") if tipo != "flatpakrepo"
                         else L("Aggiungi", "Add"))
        ok.setStyleSheet("QPushButton{background:#9ccf6a;color:#10240a;border-radius:9px;padding:7px 18px;font-weight:800;}")
        ok.clicked.connect(dlg.accept)
        pulsanti.addWidget(ok)
        rp = QWidget()
        rp.setLayout(pulsanti)
        v.addWidget(rp)
        if dlg.exec() != QDialog.DialogCode.Accepted:
            return
        self._avvia_transazione(tipo, [valore], "%s %s…" % (
            L("Installazione", "Installing") if tipo != "flatpakrepo"
            else L("Aggiunta", "Adding"), nome))

    def backend_on(self, b):
        if b == "apt": return True
        return bool(self.cfg.get(b)) and have(b)
    def _visible(self, apps, ignora_filtro=False):
        out = [a for a in apps if self.backend_on(a.backend)]
        if not ignora_filtro and self.cfg.get("filter", "all") != "all":
            out = [a for a in out if a.backend == self.cfg["filter"]]
        if self.cfg.get("sort") == "rating":
            out.sort(key=lambda a: (-a.rating, a.name.lower()))
        else:
            out.sort(key=lambda a: ((not a.installed), a.name.lower()))
        return out

    # navigation
    def go(self, key, push=True):
        # ⚠️ Il catalogo arriva da un thread e fino ad allora self.cat e' None.
        # I pulsanti del menu' pero' esistono e si cliccano da subito: senza
        # questa riga, un clic durante il caricamento diventava None.apps o
        # None.updates, e PyQt6 un'eccezione dentro uno slot la trasforma in
        # qFatal, cioe' in un core dump. Segnalato come issue #73.
        if self.cat is None:
            self.statusBar().showMessage(
                L("Sto ancora caricando il catalogo…", "Still loading the catalogue…"), 2000)
            return
        fr = getattr(self, "_fr_app", None)
        if fr is not None and fr.isVisible(): fr.hide()
        if push and (not self._hist or self._hist[-1] != (key,)): self._hist.append((key,))
        for k, b in self.nav.items(): b.setChecked(k == key)
        if key in ("home", "installed", "updates", "sources") and getattr(self, "_active_cat", None) is not None:
            self._active_cat = None; self._subcat = None
            QTimer.singleShot(0, self._build_categories)  # collapse sub-categories (deferred)
        if key == "home": self.show_home()
        elif key == "cats": self.show_categories()
        elif key == "installed": self.show_list([a for a in self.cat.apps.values() if a.installed], L("Installati", "Installed"))
        elif key == "updates": self.show_updates()
        elif key == "sources": self.show_sources()
    def go_back(self):
        if len(self._hist) > 1:
            self._hist.pop(); item = self._hist[-1]
            if item[0] == "detail": self.open_app(item[1], push=False)
            elif item[0] == "cat": self.show_category(item[1], push=False)
            elif item[0] == "search": self.show_list(item[1], item[2], push=False)
            else: self.go(item[0], push=False)
    def _clear(self):
        # every view change bumps a token so stale async workers can detect
        # they've been superseded and must not clobber the current view
        self._vtok = getattr(self, "_vtok", 0) + 1
        while self.p_lay.count():
            it = self.p_lay.takeAt(0); w = it.widget()
            if w is not None: w.setParent(None)

    def _toolbar(self):
        bar = QHBoxLayout()
        fl = QComboBox(); fl.addItem(L("Tutte le fonti", "All sources"), "all")
        for b in ("apt", "flatpak", "snap"):
            if self.backend_on(b) or b == "apt": fl.addItem(SRC_LABEL[b], b)
        # ⚠️ Un filtro salvato che punta a una fonte non installata NON si
        # applica: altrimenti la pagina esce vuota e sembra un guasto. E'
        # successo davvero con «snap» rimasto in configurazione su una scheda
        # dove snap non c'e'.
        salvato = self.cfg.get("filter", "all")
        if salvato not in ("all", "apt") and not self.backend_on(salvato):
            salvato = "all"
            self.cfg["filter"] = "all"
            save_cfg(self.cfg)
        idx = fl.findData(salvato); fl.setCurrentIndex(idx if idx >= 0 else 0)
        # ⚠️ NON ricostruire la pagina qui dentro: _refresh_current() distrugge
        # la barra, cioe' questo stesso menu, mentre Qt sta ancora elaborando il
        # clic. Al ritorno Qt cerca l'oggetto per l'accessibilita' e trova
        # memoria liberata: segmentation fault, non un'eccezione Python.
        # singleShot(0) rimanda al primo momento in cui Qt ha finito.
        fl.currentIndexChanged.connect(lambda: (self.cfg.__setitem__("filter", fl.currentData()), save_cfg(self.cfg), QTimer.singleShot(0, self._refresh_current)))
        so = QComboBox(); so.addItem(L("Nome", "Name"), "name"); so.addItem(L("Valutazione", "Rating"), "rating")
        so.setCurrentIndex(0 if self.cfg.get("sort") == "name" else 1)
        so.currentIndexChanged.connect(lambda: (self.cfg.__setitem__("sort", so.currentData()), save_cfg(self.cfg), QTimer.singleShot(0, self._refresh_current)))
        for c in (fl, so): vesti_tendina(c)
        bar.addWidget(QLabel(L("Filtro:", "Filter:"))); bar.addWidget(fl)
        bar.addSpacing(8); bar.addWidget(QLabel(L("Ordina:", "Sort:"))); bar.addWidget(so); bar.addStretch(1)
        w = QWidget(); w.setLayout(bar); return w

    def _grid(self, apps, toolbar=True):
        tutte = apps
        apps = self._visible(apps)
        if toolbar:
            tb = self._toolbar()
            cnt = QLabel("  %d" % len(apps)); cnt.setStyleSheet("color:#b9a07a;")
            tb.layout().addWidget(cnt)
            self.p_lay.addWidget(tb)
        if not apps:
            # Quante ce ne sarebbero senza filtro: se sono tante, il vuoto non
            # e' «non hai niente installato», e' «stai guardando dalla fessura
            # sbagliata». Dirlo evita di far credere che l'Hub sia rotto.
            filtro = self.cfg.get("filter", "all")
            senza = len(self._visible(tutte, ignora_filtro=True)) if toolbar else 0
            if toolbar and filtro != "all" and senza:
                msg = L("Nessun risultato con il filtro «%s».\nSenza filtro qui ci sono %d voci.",
                        "No results with the «%s» filter.\nWithout it there are %d here.") % (
                            SRC_LABEL.get(filtro, filtro), senza)
                e = QLabel(msg); e.setStyleSheet("color:#b9a07a;")
                self.p_lay.addWidget(e)
                b = QPushButton(L("Mostra tutte le fonti", "Show all sources"))
                b.setStyleSheet("QPushButton{background:rgba(216,168,73,0.18);color:#f1e3c6;border-radius:9px;padding:6px 14px;}")
                b.clicked.connect(lambda: (self.cfg.__setitem__("filter", "all"),
                                           save_cfg(self.cfg), self._refresh_current()))
                self.p_lay.addWidget(b, alignment=Qt.AlignmentFlag.AlignLeft)
            else:
                e = QLabel(L("Nessun risultato.", "No results.")); e.setStyleSheet("color:#b9a07a;")
                self.p_lay.addWidget(e)
            self.p_lay.addStretch(1); return
        self._page_apps = apps; self._page_shown = 0
        self._render_more(first=True)

    def _render_more(self, first=False):
        CHUNK = 100
        if not first:
            # drop the trailing stretch + "show more" button before adding the next page
            for _ in range(2):
                n = self.p_lay.count()
                if n:
                    it = self.p_lay.takeAt(n - 1); w = it.widget()
                    if w is not None: w.setParent(None)
        for a in self._page_apps[self._page_shown:self._page_shown + CHUNK]:
            self.p_lay.addWidget(ListCard(a, self.open_app, self.card_install, self.card_remove))
        self._page_shown += len(self._page_apps[self._page_shown:self._page_shown + CHUNK])
        rem = len(self._page_apps) - self._page_shown
        if rem > 0:
            b = QPushButton(L("Mostra altri (%d rimanenti)", "Show more (%d left)") % rem)
            b.clicked.connect(lambda: self._render_more(False))
            b.setStyleSheet("QPushButton{background:rgba(216,168,73,0.16);color:#f1e3c6;border-radius:10px;padding:9px 16px;}QPushButton:hover{background:rgba(216,168,73,0.26);}")
            self.p_lay.addWidget(b)
        self.p_lay.addStretch(1)

    def _refresh_current(self):
        cur = self._hist[-1] if self._hist else ("home",)
        if cur[0] == "cat": self.show_category(cur[1], push=False)
        elif cur[0] == "search": self.show_list(cur[1], cur[2], push=False)
        elif cur[0] == "list": self.show_list(cur[1], cur[2], push=False)
        elif cur[0] in ("home", "cats", "installed", "updates", "sources"): self.go(cur[0], push=False)

    # panes
    def card_install(self, app):
        self._snap_channel = None  # default channel for a direct card install
        self.do_op(app, "install")

    def card_remove(self, app):
        if QMessageBox.question(self, "SkillFishOS",
                L("Rimuovere %s?", "Remove %s?") % (app.name,)) == QMessageBox.StandardButton.Yes:
            self.do_op(app, "remove")

    def _section_title(self, text):
        l = QLabel(text); l.setStyleSheet("color:#e8c878;font-size:17px;font-weight:800;margin-top:8px;margin-bottom:2px;"); return l

    def _tile_grid(self, apps):
        host = QWidget(); g = QGridLayout(host); g.setSpacing(14); g.setContentsMargins(0, 0, 0, 0)
        cols = max(1, (self.width() - 250) // 362)
        for i, a in enumerate(apps):
            g.addWidget(GridCard(a, self.open_app, self.card_install), i // cols, i % cols)
        g.setColumnStretch(cols, 1)
        return host

    def show_home(self):
        self.title.setText(""); self._clear()
        # ⚠️ Si riconosce dal NOME del pacchetto, non dall'origine: le nostre
        # applicazioni installate da un .deb locale non hanno origine, e la
        # vetrina restava senza il Tuner, l'Hub, la Ventola e le altre.
        def e_nostro(a):
            if a.backend != "apt":
                return False
            return (a.origin == AETHERIUM
                    or (a.pkgid or "").startswith(("skillfish-", "skillfishos-"))
                    or (a.appid or "").startswith("os.skillfish."))
        # prima le applicazioni, poi i pacchetti di servizio: in vetrina si
        # mette cio' che si apre, non la base del sistema.
        nostri = [a for a in self.cat.apps.values() if e_nostro(a)]
        ours = (sorted([a for a in nostri if a.appid.startswith("os.skillfish.")],
                       key=lambda a: a.name.lower())
                + sorted([a for a in nostri if not a.appid.startswith("os.skillfish.")],
                         key=lambda a: a.name.lower()))
        if ours:
            self.p_lay.addWidget(self._section_title(L("In evidenza · SkillFishOS", "Featured · SkillFishOS")))
            self.p_lay.addWidget(self._tile_grid(ours))
        pop = sorted([a for a in self.cat.apps.values() if a.rating_n and self.backend_on(a.backend)],
                     key=lambda a: (-a.rating_n, -a.rating))[:24]
        if pop:
            self.p_lay.addWidget(self._section_title(L("Più popolari", "Most popular")))
            self.p_lay.addWidget(self._tile_grid(pop))
        best = sorted([a for a in self.cat.apps.values() if a.rating >= 4.6 and a.rating_n >= 20 and self.backend_on(a.backend)],
                      key=lambda a: (-a.rating, -a.rating_n))[:18]
        if best:
            self.p_lay.addWidget(self._section_title(L("Scelta della redazione", "Editor's choice")))
            self.p_lay.addWidget(self._tile_grid(best))
        self.p_lay.addStretch(1); self.stack.setCurrentWidget(self.p_scroll)

    def show_categories(self):
        # categories now live in the sidebar; this just lands on the first one
        self.show_category(CATEGORIES[0][0])

    def show_category(self, key, push=True):
        # Stessa ragione di go(): i pulsanti delle categorie nella barra
        # laterale arrivano qui senza passare di la'.
        if self.cat is None:
            self.statusBar().showMessage(
                L("Sto ancora caricando il catalogo…", "Still loading the catalogue…"), 2000)
            return
        fr = getattr(self, "_fr_app", None)
        if fr is not None and fr.isVisible(): fr.hide()
        if push: self._hist.append(("cat", key))
        if getattr(self, "_active_cat", None) != key: self._subcat = None
        self._active_cat = key; self._last_cat = key
        # defer sidebar rebuild: it deletes the very button that emitted the click,
        # so doing it synchronously inside the slot freezes/crashes the UI
        QTimer.singleShot(0, self._build_categories)
        sub_name = dict(SUBCATS.get(key, [])).get(self._subcat) if self._subcat else None
        self.title.setText(CAT_NAME.get(key, key) + ("  ·  " + sub_name if sub_name else ""))
        self._clear(); tok = self._vtok
        apps = self.cat.by_cat.get(key, [])
        shown = [a for a in apps if self._subcat in (a.cats or [])] if self._subcat else apps
        self._grid(shown)
        self.stack.setCurrentWidget(self.p_scroll)
        # ⚠️ UNA VOLTA SOLA PER CATEGORIA, o si torna al ciclo infinito:
        # questa ricerca finisce in _append_snap, che chiama
        # _refresh_current(), che richiama show_category, che la
        # rilancerebbe. La pagina si svuotava e si ridisegnava per sempre.
        if (self.backend_on("snap") and not self._subcat
                and key not in getattr(self, "_snap_fatte", set())):
            self._snapw = Worker(lambda k=key: self.cat.snap_category(k))
            self._snapw.done.connect(lambda a2, k=key, t=tok: self._append_snap(a2, k, t)); self._snapw.start()

    def _click_category(self, key):
        # Discover-style toggle: re-clicking the open macro category collapses it
        if getattr(self, "_active_cat", None) == key:
            self._active_cat = None; self._subcat = None
            QTimer.singleShot(0, self._build_categories)  # deferred: don't delete sender mid-click
            self.go("home")
        else:
            self.show_category(key)

    def _set_subcat(self, val):
        self._subcat = val
        self.show_category(self._active_cat, push=False)
    def _append_snap(self, apps, key, tok):
        # drop if the user navigated away since this snap fetch started
        if tok != getattr(self, "_vtok", 0): return
        cur = self._hist[-1] if self._hist else None
        if not apps or not cur or cur != ("cat", key): return
        # segnata PRIMA di ridisegnare: il ridisegno passa da
        # show_category, e deve trovarla gia' fatta.
        self._snap_fatte = getattr(self, "_snap_fatte", set()) | {key}
        self._refresh_current()

    def show_list(self, apps, title, push=True):
        fr = getattr(self, "_fr_app", None)
        if fr is not None and fr.isVisible(): fr.hide()
        if push: self._hist.append(("search" if title and L("Ricerca", "Search") in str(title) else "list", apps, title))
        self.title.setText(title if isinstance(title, str) else ""); self._clear(); self._grid(apps)
        self.stack.setCurrentWidget(self.p_scroll)

    def do_search(self):
        q = self.search.text().strip()
        if len(q) < 2 or self.cat is None: return
        # search spans all categories: drop any category/nav highlight (Discover-style)
        self._active_cat = None; self._subcat = None
        for b in self.nav.values(): b.setChecked(False)
        QTimer.singleShot(0, self._build_categories)
        self.title.setText(L("Ricerca: ", "Search: ") + q); self._clear()
        tok = self._vtok
        sl = QLabel(L("Cerco…", "Searching…")); sl.setStyleSheet("color:#b9a07a;font-size:14px;")
        self.p_lay.addWidget(sl); self.p_lay.addStretch(1); self.stack.setCurrentWidget(self.p_scroll)
        self._sw = Worker(lambda: self.cat.search(q))
        self._sw.done.connect(lambda res, qq=q, t=tok: self._search_done(res or [], qq, t)); self._sw.start()
    def _search_done(self, res, q, tok):
        if tok != getattr(self, "_vtok", 0): return  # user navigated away; don't clobber their view
        self._hist.append(("search", res, L("Ricerca: ", "Search: ") + q)); self._clear(); self._grid(res)

    # ---------- detail ----------
    def _riquadro_app(self):
        u"""Il riquadro che ospita la scheda, creato la prima volta che serve."""
        if getattr(self, "_fr_app", None) is not None:
            return self._fr_app
        fr = QFrame(self.stack)
        fr.setObjectName("riquadroApp")
        fr.setStyleSheet("QFrame#riquadroApp{background:#15110a;"
                         "border:1px solid rgba(216,168,73,0.35);border-radius:14px;}")
        lay = QVBoxLayout(fr)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.setSpacing(0)
        testa = QHBoxLayout()
        testa.setContentsMargins(14, 8, 8, 8)
        self._fr_titolo = QLabel("")
        self._fr_titolo.setStyleSheet("color:#e8c878;font-size:14px;font-weight:700;background:transparent;")
        testa.addWidget(self._fr_titolo)
        testa.addStretch(1)
        chiudi = QPushButton("×")
        chiudi.setFixedSize(30, 26)
        chiudi.setStyleSheet("QPushButton{background:rgba(216,168,73,0.16);color:#f1e3c6;"
                             "border:none;border-radius:8px;font-size:17px;font-weight:700;}"
                             "QPushButton:hover{background:rgba(216,168,73,0.30);}")
        chiudi.clicked.connect(self.chiudi_riquadro_app)
        testa.addWidget(chiudi)
        tw = QWidget()
        tw.setStyleSheet("background:transparent;")
        tw.setLayout(testa)
        lay.addWidget(tw)
        self.fr_scroll = QScrollArea()
        self.fr_scroll.setWidgetResizable(True)
        self.fr_scroll.setFrameShape(QFrame.Shape.NoFrame)
        self.fr_scroll.setStyleSheet("QScrollArea{background:transparent;}"
                                     "QScrollBar:vertical{width:10px;background:transparent;}")
        lay.addWidget(self.fr_scroll, 1)
        self._fr_app = fr
        fr.hide()
        return fr

    def _posiziona_riquadro_app(self):
        u"""Sopra all'area centrale, con un margine che lascia vedere l'elenco.

        ⚠️ Il margine non e' estetica: e' quello che fa capire che sotto c'e'
        ancora la pagina di prima, e che questo si chiude.
        """
        fr = getattr(self, "_fr_app", None)
        if fr is None or not fr.isVisible():
            return
        r = self.stack.rect()
        m = 18
        fr.setGeometry(r.x() + m, r.y() + m, max(320, r.width() - 2 * m),
                       max(240, r.height() - 2 * m))
        fr.raise_()

    def chiudi_riquadro_app(self):
        fr = getattr(self, "_fr_app", None)
        if fr is not None:
            fr.hide()
        # ⚠️ Si toglie anche dalla cronologia, o il tasto «indietro» riaprirebbe
        # la scheda che l'utente ha appena chiuso.
        if self._hist and self._hist[-1][0] == "detail":
            self._hist.pop()
        self.stack.setCurrentWidget(self.p_scroll)

    def open_app(self, app, push=True):
        if push: self._hist.append(("detail", app))
        self.title.setText(""); self._snap_channel = None
        w = QWidget(); v = QVBoxLayout(w); v.setContentsMargins(28, 18, 28, 24); v.setSpacing(16)
        # ---------- hero ----------
        hero = QHBoxLayout(); hero.setSpacing(18)
        ic = QLabel(); ic.setFixedSize(96, 96); ic.setPixmap(pixmap_for(app, 96)); ic.setStyleSheet("background:transparent;")
        hero.addWidget(ic, 0, Qt.AlignmentFlag.AlignTop)
        url = getattr(app, "_snap_icon_url", None)
        if url and not app.icon:
            self._dil = ImgLoad([url]); self._dil.ready.connect(lambda _i, pm: ic.setPixmap(pm.scaled(96, 96, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))); self._dil.start()
        tcol = QVBoxLayout(); tcol.setSpacing(4)
        nm = QLabel(app.name); nm.setStyleSheet("font-size:28px;font-weight:800;color:#f1e3c6;background:transparent;"); tcol.addWidget(nm)
        if app.developer:
            dv = QLabel(app.developer); dv.setStyleSheet("color:#b9a07a;font-size:14px;background:transparent;"); tcol.addWidget(dv)
        if app.summary:
            sm = QLabel(app.summary); sm.setStyleSheet("color:#cdbb95;font-size:14px;background:transparent;"); sm.setWordWrap(True); tcol.addWidget(sm)
        if app.rating_n:
            rr = QHBoxLayout(); rr.setSpacing(8); rr.addWidget(star_widget(app.rating, 0, 16))
            rl = QLabel("%.1f · %s" % (app.rating, L("%d valutazioni", "%d ratings") % app.rating_n))
            rl.setStyleSheet("color:#b9a07a;font-size:13px;background:transparent;"); rr.addWidget(rl); rr.addStretch(1); tcol.addLayout(rr)
        hero.addLayout(tcol, 1)
        bc = QVBoxLayout(); bc.setSpacing(8)
        if app.installed:
            if app.backend in ("flatpak", "snap") or self._desktop_for(app):
                opb = QPushButton(L("Apri", "Open")); opb.setCursor(Qt.CursorShape.PointingHandCursor); opb.setFixedWidth(150); opb.clicked.connect(lambda: self.launch(app))
                opb.setStyleSheet("QPushButton{background:rgba(216,168,73,0.22);color:#f1e3c6;border:none;border-radius:10px;padding:10px;font-weight:700;}QPushButton:hover{background:rgba(216,168,73,0.34);}"); bc.addWidget(opb)
            rm = QPushButton(L("Rimuovi", "Remove")); rm.setCursor(Qt.CursorShape.PointingHandCursor); rm.setFixedWidth(150); rm.clicked.connect(lambda: self.card_remove(app))
            rm.setStyleSheet("QPushButton{background:rgba(207,138,106,0.14);color:#e0b0a0;border:1px solid rgba(207,138,106,0.45);border-radius:10px;padding:10px;font-weight:700;}QPushButton:hover{background:#cf6a4a;color:#fff;}"); bc.addWidget(rm)
        else:
            ins = QPushButton(L("Installa", "Install")); ins.setCursor(Qt.CursorShape.PointingHandCursor); ins.setFixedWidth(150); ins.clicked.connect(lambda: self.do_op(app, "install"))
            ins.setStyleSheet("QPushButton{background:#9ccf6a;color:#10240a;border:none;border-radius:10px;padding:10px;font-weight:800;}QPushButton:hover{background:#aee07c;}"); bc.addWidget(ins)
        bc.addStretch(1); hero.addLayout(bc, 0); v.addLayout(hero)
        # snap channel selector
        self._chanrow = QHBoxLayout(); v.addLayout(self._chanrow)
        if app.backend == "snap" and not app.installed: self._load_channels(app)
        # ---------- metadata strip ----------
        mf = QFrame(); mf.setStyleSheet("QFrame{background:rgba(255,255,255,0.03);border:1px solid rgba(216,168,73,0.12);border-radius:10px;}QLabel{background:transparent;border:none;}")
        ml = QHBoxLayout(mf); ml.setContentsMargins(18, 10, 18, 10); ml.setSpacing(26)
        def cell(lbl, val):
            c = QVBoxLayout(); c.setSpacing(1)
            a = QLabel(lbl); a.setStyleSheet("color:#8a7a55;font-size:11px;"); b = QLabel(val); b.setStyleSheet("color:#e8c878;font-size:13px;font-weight:600;")
            c.addWidget(a); c.addWidget(b); ml.addLayout(c)
        cell(L("Fonte", "Source"), src_label(app))
        if app.ver: cell(L("Versione", "Version"), app.ver)
        if app.size_dl: cell(L("Dimensione", "Size"), human(app.size_dl))
        elif app.size_inst: cell(L("Dimensione", "Size"), human(app.size_inst))
        if app.license: cell(L("Licenza", "License"), app.license)
        if app.confinement: cell("Sandbox", app.confinement)
        ml.addStretch(1)
        if app.homepage:
            hb = QLabel('<a style="color:#9ccf6a;text-decoration:none;" href="%s">%s ↗</a>' % (html.escape(app.homepage), L("Sito web", "Website")))
            hb.setOpenExternalLinks(True); ml.addWidget(hb)
        v.addWidget(mf)
        # ---------- screenshot carousel ----------
        if app.screens:
            v.addWidget(Carousel(app.screens, 400))
        # ---------- description ----------
        d = app.description or app.summary or ""
        if d.strip():
            desc = QLabel(); desc.setTextFormat(Qt.TextFormat.RichText); desc.setWordWrap(True)
            desc.setStyleSheet("color:#d9c8a8;font-size:14px;background:transparent;")
            desc.setText(d if d.strip().startswith("<") else "<p>%s</p>" % html.escape(d)); v.addWidget(desc)
        # ---------- what's new ----------
        if app.releases:
            v.addWidget(self._section_title(L("Novità", "What's new")))
            for ver, body in app.releases[:3]:
                rv = QLabel(("<b>%s</b>&nbsp; " % html.escape(ver)) + (body if body.strip().startswith("<") else html.escape(body)))
                rv.setTextFormat(Qt.TextFormat.RichText); rv.setWordWrap(True); rv.setStyleSheet("color:#c9b8a8;font-size:13px;background:transparent;"); v.addWidget(rv)
        # ---------- permissions ----------
        perms = self._permissions(app)
        if perms:
            v.addWidget(self._section_title(L("Permessi", "Permissions")))
            pv = QLabel(perms); pv.setWordWrap(True); pv.setStyleSheet("color:#b9a07a;font-size:13px;background:transparent;"); v.addWidget(pv)
        # ---------- reviews ----------
        self._rev_box = QVBoxLayout(); v.addLayout(self._rev_box)
        if app.odrs_id: self._load_reviews(app)
        if app.odrs_id:
            # ⚠️ Il pulsante sta qui e non dentro a _show_reviews: li' verrebbe
            # disegnato solo quando le recensioni ci sono gia', e la prima
            # recensione di un programma non la scriverebbe mai nessuno.
            scrivi = QPushButton(L("Scrivi una recensione", "Write a review"))
            scrivi.setStyleSheet("QPushButton{background:rgba(216,168,73,0.18);color:#f1e3c6;"
                                 "border-radius:9px;padding:7px 16px;}")
            scrivi.clicked.connect(lambda _c=False, a=app: self.scrivi_recensione(a))
            v.addWidget(scrivi, 0, Qt.AlignmentFlag.AlignLeft)
        v.addStretch(1)
        # la scheda si apre SOPRA all'elenco, non al suo posto
        fr = self._riquadro_app()
        self.fr_scroll.setWidget(w)
        self._fr_titolo.setText(app.name)
        fr.show()
        self._posiziona_riquadro_app()

    def _desktop_for(self, app):
        for d in ("/usr/share/applications", os.path.expanduser("~/.local/share/applications")):
            p = os.path.join(d, app.pkgid + ".desktop")
            if os.path.exists(p): return p
        return None
    def launch(self, app):
        if app.backend == "flatpak": subprocess.Popen(["flatpak", "run", app.pkgid])
        elif app.backend == "snap": subprocess.Popen(["snap", "run", app.pkgid])
        else:
            dp = self._desktop_for(app)
            if dp: subprocess.Popen(["gio", "launch", dp])

    def _load_channels(self, app):
        def fetch():
            d = snapd_get("/v2/find?" + urllib.parse.urlencode({"name": app.pkgid}))
            r = d.get("result", [])
            return list((r[0].get("channels", {}) if r else {}).keys())
        self._cw = Worker(fetch); self._cw.done.connect(lambda chs: self._set_channels(app, chs or [])); self._cw.start()
    def _set_channels(self, app, chs):
        if not chs or self._chanrow is None: return
        lab = QLabel(L("Canale:", "Channel:")); lab.setStyleSheet("color:#b9a07a;")
        cb = QComboBox(); cb.addItems(chs); vesti_tendina(cb)
        self._snap_channel = chs[0]; cb.currentTextChanged.connect(lambda t: setattr(self, "_snap_channel", t))
        self._chanrow.addWidget(lab); self._chanrow.addWidget(cb); self._chanrow.addStretch(1)

    def _permissions(self, app):
        try:
            if app.backend == "flatpak":
                rc, out, _ = run(["flatpak", "info", "--show-permissions", app.pkgid], 12) if app.installed else (1, "", "")
                if rc != 0:
                    # Anche qui va detto l'ambito: nominare "flathub" quando il
                    # remote esiste sia a sistema sia per utente fa fallire il
                    # comando con "No remote chosen to resolve 'flathub' which
                    # exists in multiple installations", e la scheda dell'app
                    # restava senza l'elenco dei permessi.
                    rc, out, _ = run(["flatpak", "remote-info", "--system",
                                      "--show-metadata", "flathub", app.pkgid], 20)
                share = re.search(r'shared=([^\n]+)', out); sock = re.search(r'sockets=([^\n]+)', out)
                dev = re.search(r'devices=([^\n]+)', out); fs = re.search(r'filesystems=([^\n]+)', out)
                bits = []
                if share: bits.append(L("rete/ipc: ", "network/ipc: ") + share.group(1).strip())
                if sock: bits.append(L("socket: ", "sockets: ") + sock.group(1).strip())
                if dev: bits.append(L("dispositivi: ", "devices: ") + dev.group(1).strip())
                if fs: bits.append(L("filesystem: ", "filesystem: ") + fs.group(1).strip()[:120])
                return "  ·  ".join(bits)
            if app.backend == "snap":
                c = app.confinement or "strict"
                return L("Confinamento: %s", "Confinement: %s") % c
            if app.backend == "apt":
                # Un .deb non dichiara permessi come un Flatpak, ma se ha
                # bisogno di root lo dichiara a polkit: quelle sono le sue
                # autorizzazioni, e sono gia' scritte e tradotte.
                return permessi_apt(app.pkgid, app.appid)
        except Exception:
            return ""
        return ""

    def scrivi_recensione(self, app):
        u"""Le stelle e due righe di testo, e li manda a ODRS.

        ⚠️ QUELLO CHE SI SCRIVE QUI DIVENTA PUBBLICO. ODRS e' l'archivio comune
        delle recensioni (lo stesso che legge GNOME Software), non un quaderno
        locale: una volta spedita, la recensione la leggono tutti. La finestra
        lo dice prima di far premere il pulsante, perche' dopo e' tardi.

        ⚠️ E NON SI MANDA NIENTE CHE IDENTIFICHI LA MACCHINA. L'identificativo
        richiesto dal servizio e' una impronta, la stessa che gia' si usa per
        leggere le recensioni: serve solo perche' ODRS sappia riconoscere due
        voti della stessa persona.
        """
        dlg = QDialog(self)
        dlg.setWindowTitle("SkillFishOS Hub")
        dlg.setMinimumWidth(560)
        v = QVBoxLayout(dlg)
        t = QLabel(L("La tua recensione di %s", "Your review of %s") % app.name)
        t.setStyleSheet("color:#e8c878;font-size:16px;font-weight:700;")
        v.addWidget(t)

        # le stelle: si cliccano, e quella scelta resta accesa
        stelle = {"n": 0}
        rs = QHBoxLayout()
        rs.setSpacing(2)
        bottoni = []
        def ridisegna():
            for i, b in enumerate(bottoni):
                b.setText("★" if i < stelle["n"] else "☆")
        def scegli(i):
            stelle["n"] = i + 1
            ridisegna()
        for i in range(5):
            b = QToolButton()
            b.setText("☆")
            b.setStyleSheet("QToolButton{color:#e8c878;font-size:26px;border:none;background:transparent;}")
            b.setCursor(Qt.CursorShape.PointingHandCursor)
            b.clicked.connect(lambda _c=False, k=i: scegli(k))
            bottoni.append(b)
            rs.addWidget(b)
        rs.addStretch(1)
        rw = QWidget(); rw.setLayout(rs); v.addWidget(rw)

        titolo = QLineEdit()
        titolo.setPlaceholderText(L("In una riga: com'e'?", "In one line: what is it like?"))
        v.addWidget(titolo)
        corpo = QPlainTextEdit()
        corpo.setPlaceholderText(L("Cosa fa bene, cosa no, su che macchina.",
                                   "What it does well, what it does not, on which machine."))
        corpo.setFixedHeight(120)
        v.addWidget(corpo)
        nome = QLineEdit()
        nome.setText(os.environ.get("USER", "") or "anon")
        nome.setPlaceholderText(L("Come vuoi firmarti", "How you want to sign it"))
        v.addWidget(nome)

        av = QLabel(L("Va nell'archivio pubblico delle recensioni (ODRS), quello che "
                      "leggono anche gli altri programmi: la vedranno tutti, e non si "
                      "cancella da qui.",
                      "This goes to the public review database (ODRS), the one other "
                      "software reads too: everyone will see it, and it cannot be "
                      "deleted from here."))
        av.setWordWrap(True)
        av.setStyleSheet("color:#d8a849;font-size:12px;")
        v.addWidget(av)
        esito = QLabel("")
        esito.setWordWrap(True)
        esito.setStyleSheet("color:#b9a07a;font-size:12px;")
        v.addWidget(esito)

        riga = QHBoxLayout(); riga.addStretch(1)
        annulla = QPushButton(L("Annulla", "Cancel"))
        annulla.setStyleSheet("QPushButton{background:rgba(216,168,73,0.18);color:#f1e3c6;border-radius:9px;padding:7px 16px;}")
        annulla.clicked.connect(dlg.reject)
        riga.addWidget(annulla)
        manda = QPushButton(L("Pubblica", "Publish"))
        manda.setStyleSheet("QPushButton{background:#9ccf6a;color:#10240a;border-radius:9px;padding:7px 18px;font-weight:800;}")
        riga.addWidget(manda)
        rp = QWidget(); rp.setLayout(riga); v.addWidget(rp)

        def invia():
            if not stelle["n"]:
                esito.setText(L("Manca il voto: quante stelle?", "No rating yet: how many stars?"))
                return
            if not titolo.text().strip():
                esito.setText(L("Manca il titolo.", "The title is missing."))
                return
            manda.setEnabled(False)
            esito.setText(L("Invio…", "Sending…"))
            dati = {
                "app_id": (app.odrs_id if "." in app.odrs_id else app.odrs_id + ".desktop"),
                "locale": LANG,
                "distro": "SkillFishOS",
                "version": app.ver or "",
                # ⚠️ ODRS vuole il voto da 0 a 100 a passi di 20, non da 1 a 5:
                # mandando 5 si pubblica una recensione da una stella.
                "rating": stelle["n"] * 20,
                "user_hash": hashlib.sha1(("skillfishos" + os.uname().nodename).encode()).hexdigest(),
                "user_display": nome.text().strip() or "anon",
                "summary": titolo.text().strip()[:70],
                "description": corpo.toPlainText().strip()[:3000],
            }
            def spedisci():
                try:
                    req = urllib.request.Request(
                        ODRS + "/submit", data=json.dumps(dati).encode(),
                        headers={"Content-Type": "application/json"})
                    with urllib.request.urlopen(req, timeout=20) as r:
                        return (r.status, r.read().decode("utf-8", "replace")[:200])
                except urllib.error.HTTPError as e:
                    return (e.code, e.read().decode("utf-8", "replace")[:200])
                except Exception as e:
                    return (0, str(e)[:200])
            def finito(risposta):
                codice, testo = risposta or (0, "")
                if codice in (200, 201):
                    esito.setText(L("Pubblicata. Grazie.", "Published. Thank you."))
                    QTimer.singleShot(1200, dlg.accept)
                    self._load_reviews(app)
                else:
                    manda.setEnabled(True)
                    esito.setText(L("Non e' andata (%s). %s", "It did not go through (%s). %s")
                                  % (codice or "-", testo))
            self._rev_send = Worker(spedisci)
            self._rev_send.done.connect(finito)
            self._rev_send.start()

        manda.clicked.connect(invia)
        dlg.exec()

    def _load_reviews(self, app):
        def fetch():
            try:
                uh = hashlib.sha1(("skillfishos" + os.uname().nodename).encode()).hexdigest()
                aid = app.odrs_id if app.odrs_id.endswith(".desktop") or "." in app.odrs_id else app.odrs_id + ".desktop"
                body = json.dumps({"user_hash": uh, "app_id": aid, "locale": LANG, "distro": "SkillFishOS",
                                   "version": app.ver or "", "limit": 8}).encode()
                req = urllib.request.Request(ODRS + "/fetch", data=body, headers={"Content-Type": "application/json"})
                with urllib.request.urlopen(req, timeout=15) as r:
                    return json.loads(r.read().decode())
            except Exception:
                return []
        self._rw = Worker(fetch); self._rw.done.connect(self._show_reviews); self._rw.start()
    def _show_reviews(self, reviews):
        # ⚠️ ODRS restituisce anche voci senza testo: un voto e basta, o un
        # record vuoto. Disegnate cosi' com'erano davano un riquadro vuoto
        # firmato «anon» sotto al titolo «Recensioni» — sembrava un guasto
        # nostro. Una recensione senza parole non e' una recensione.
        reviews = [r for r in (reviews or [])
                   if (r.get("summary") or "").strip()
                   or (r.get("description") or "").strip()]
        if not reviews or self._rev_box is None: return
        hl = QLabel(L("Recensioni", "Reviews")); hl.setStyleSheet("color:#e8c878;font-weight:700;margin-top:8px;"); self._rev_box.addWidget(hl)
        for rv in reviews[:8]:
            fr = QFrame(); fr.setStyleSheet("QFrame{background:rgba(255,255,255,0.03);border-radius:8px;}")
            fv = QVBoxLayout(fr); fv.setContentsMargins(10, 7, 10, 7); fv.setSpacing(2)
            stars = int(round((rv.get("rating", 0) or 0) / 20.0))
            hdr = QLabel(("★" * stars + "☆" * (5 - stars)) + "   " + html.escape(rv.get("summary", "")))
            hdr.setStyleSheet("color:#e8c878;font-weight:600;"); fv.addWidget(hdr)
            bd = QLabel(html.escape(rv.get("description", ""))); bd.setWordWrap(True); bd.setStyleSheet("color:#d9c8a8;font-size:12px;"); fv.addWidget(bd)
            au = QLabel("— %s" % html.escape(rv.get("user_display", "anon") or "anon")); au.setStyleSheet("color:#b9a07a;font-size:11px;"); fv.addWidget(au)
            self._rev_box.addWidget(fr)

    # ---------- operations ----------
    def do_op(self, app, action):
        # ⚠️ Per APT si passa sempre dalla transazione staccata: un'installazione
        # o una rimozione interrotta a meta' lascia dpkg da riparare a mano, e
        # a interromperla basta che questa finestra muoia. Flatpak e snap no:
        # li' un'interruzione al massimo lascia un file da riscaricare.
        if app.backend == "apt":
            azione = "installa" if action == "install" else "rimuovi"
            verbo = (L("Installazione", "Installing") if action == "install"
                     else L("Rimozione", "Removing"))
            self._avvia_transazione(azione, [app.pkgid], "%s %s…" % (verbo, app.name))
            return
        if action == "install":
            argv = self._install_cmd(app); verb = L("Installazione", "Installing")
        else:
            argv = self._remove_cmd(app); verb = L("Rimozione", "Removing")
        if not argv:
            self.statusBar().showMessage(L("Backend non supportato: ", "Unsupported backend: ") + app.backend, 5000); return
        self._run_op(argv, "%s %s…" % (verb, app.name))

    # L'AMBITO DEI FLATPAK VA DETTO SEMPRE.
    #
    # Qui c'era `flatpak install -y --noninteractive flathub <id>`, senza
    # --system ne' --user. Su questa macchina flathub e' configurato in
    # ENTRAMBI gli ambiti, e flatpak si rifiuta:
    #
    #   error: Remote 'flathub' found in multiple installations,
    #          unable to proceed in non-interactive mode
    #
    # Non poteva nemmeno chiedere all'utente quale usare, perche'
    # --noninteractive glielo vieta. Risultato: nessuna installazione dall'Hub
    # funzionava, e l'errore non era ovvio perche' parla di "installations"
    # intendendo gli ambiti di flatpak, non i programmi.
    #
    # Si installa a SISTEMA, come gia' si fa per apt e snap: e' un centro
    # software di macchina, non del singolo utente, e cosi' l'app la vedono
    # tutti. Serve pkexec, esattamente come per gli altri due backend.
    def _install_cmd(self, app):
        if app.backend == "apt": return ["pkexec", HELPER, "apt-install", app.pkgid]
        if app.backend == "flatpak":
            return ["pkexec", "flatpak", "install", "-y", "--noninteractive",
                    "--system", "flathub", app.pkgid]
        if app.backend == "snap":
            ch = getattr(self, "_snap_channel", None)
            return ["pkexec", "snap", "install", app.pkgid] + (["--channel", ch] if ch else [])
        return None

    def _flatpak_scope(self, pkgid):
        """Dove e' installato davvero: --user o --system.

        Non si puo' rimuovere sempre da --system: sulla scheda ci sono 9 app
        installate per utente (le ha messe EmuDeck) e 6 a sistema. Disinstallare
        con l'ambito sbagliato fallisce dicendo che l'app non e' installata,
        che per chi guarda e' semplicemente falso.
        """
        rc, _, _ = run(["flatpak", "info", "--user", pkgid], 10)
        return "--user" if rc == 0 else "--system"

    def _remove_cmd(self, app):
        if app.backend == "apt": return ["pkexec", HELPER, "apt-remove", app.pkgid]
        if app.backend == "flatpak":
            scope = self._flatpak_scope(app.pkgid)
            argv = ["flatpak", "uninstall", "-y", "--noninteractive", scope, app.pkgid]
            # per l'ambito utente non serve nessun privilegio, e chiederlo
            # sarebbe solo una richiesta di password in piu' senza motivo
            return argv if scope == "--user" else ["pkexec"] + argv
        if app.backend == "snap": return ["pkexec", "snap", "remove", app.pkgid]
        return None

    def _run_op(self, argv, title):
        dlg = QDialog(self); dlg.setWindowTitle("SkillFishOS Hub"); dlg.resize(660, 440)
        v = QVBoxLayout(dlg); v.addWidget(QLabel(title))
        log = QPlainTextEdit(); log.setReadOnly(True); v.addWidget(log)
        bar = QProgressBar(); bar.setRange(0, 0); v.addWidget(bar)
        cb = QPushButton(L("Chiudi", "Close")); cb.setEnabled(False); cb.clicked.connect(dlg.accept); v.addWidget(cb)
        self._op = Op(argv); self._op.line.connect(log.appendPlainText)
        self._op.pct.connect(lambda p: (bar.setRange(0, 100), bar.setValue(p)))
        def fin(rc):
            bar.setRange(0, 100); bar.setValue(100); cb.setEnabled(True)
            log.appendPlainText("\n" + (L("✓ Completato.", "✓ Done.") if rc == 0 else L("! Operazione fallita (rc=%d)", "! Operation failed (rc=%d)") % rc))
            self.reload_catalog()
        self._op.done.connect(fin); self._op.start(); dlg.exec()

    # ---------- updates ----------
    def show_updates(self):
        self.title.setText(L("Aggiornamenti", "Updates")); self._clear()
        info = QLabel(L("Controllo aggiornamenti…", "Checking for updates…")); info.setStyleSheet("color:#b9a07a;font-size:14px;")
        self.p_lay.addWidget(info); self.p_lay.addStretch(1); self.stack.setCurrentWidget(self.p_scroll)
        tok = self._vtok
        self._ut = Worker(self.cat.updates); self._ut.done.connect(lambda u, t=tok: self._updates_done(u or [], t)); self._ut.start()
    def _updates_done(self, ups, tok):
        if tok != getattr(self, "_vtok", 0): return  # user navigated away
        ups = [u for u in ups if u[0] == "firmware" or self.backend_on(u[0])]
        pacchetti = [u for u in ups if u[0] != "firmware"]
        firmware = [u for u in ups if u[0] == "firmware"]
        self._clear(); top = QHBoxLayout(); n = len(ups)
        t = QLabel(L("%d aggiornamenti disponibili", "%d updates available") % n if n
                   else L("Tutto aggiornato ✓", "Everything is up to date ✓"))
        t.setStyleSheet("color:#e8c878;font-size:16px;font-weight:700;"); top.addWidget(t); top.addStretch(1)
        if pacchetti:
            allb = QPushButton(L("Aggiorna tutto", "Update all")); allb.clicked.connect(self.update_all)
            allb.setStyleSheet("QPushButton{background:#9ccf6a;color:#10240a;border-radius:10px;padding:8px 20px;font-weight:800;}"); top.addWidget(allb)
        hw = QWidget(); hw.setLayout(top); self.p_lay.addWidget(hw)
        for backend, name, oldv, newv, _id in pacchetti:
            row = QFrame(); rl = QHBoxLayout(row); rl.setContentsMargins(12, 8, 12, 8); row.setStyleSheet("QFrame{background:rgba(255,255,255,0.03);border:1px solid rgba(216,168,73,0.14);border-radius:10px;}")
            nm = QLabel(name); nm.setStyleSheet("color:#f1e3c6;font-weight:600;"); rl.addWidget(nm); rl.addWidget(badge(SRC_LABEL.get(backend, backend))); rl.addStretch(1)
            if newv: rl.addWidget(QLabel("→ %s" % newv))
            self.p_lay.addWidget(row)
        # ⚠️ IL FIRMWARE STA PER CONTO SUO, E NON LO PORTA VIA «AGGIORNA TUTTO».
        # Un pacchetto sbagliato si reinstalla; un firmware sbagliato lascia la
        # scheda dov'e'. Perche' parta ci vuole un pulsante su QUEL dispositivo e
        # un si' esplicito: e' l'unica operazione dell'Hub che non si puo'
        # disfare.
        if firmware:
            fl = QLabel(L("Firmware", "Firmware"))
            fl.setStyleSheet("color:#e8c878;font-size:16px;font-weight:700;margin-top:14px;")
            self.p_lay.addWidget(fl)
            av = QLabel(L("Il firmware non si disinstalla: se qualcosa va storto non "
                          "si torna indietro da soli. Aggiornalo solo se sai perché "
                          "lo stai facendo, e non mentre la scheda sta lavorando.",
                          "Firmware cannot be uninstalled: if something goes wrong "
                          "there is no way back on your own. Update it only if you "
                          "know why you are doing it, and not while the machine is "
                          "busy."))
            av.setWordWrap(True); av.setStyleSheet("color:#d8a849;font-size:12px;")
            self.p_lay.addWidget(av)
            for _b, name, oldv, newv, ident in firmware:
                row = QFrame(); rl = QHBoxLayout(row); rl.setContentsMargins(12, 8, 12, 8)
                row.setStyleSheet("QFrame{background:rgba(216,168,73,0.06);border:1px solid rgba(216,168,73,0.28);border-radius:10px;}")
                nm = QLabel(name); nm.setStyleSheet("color:#f1e3c6;font-weight:600;"); rl.addWidget(nm)
                if oldv: rl.addWidget(QLabel("%s → %s" % (oldv, newv or "?")))
                rl.addStretch(1)
                b = QPushButton(L("Aggiorna", "Update"))
                b.setStyleSheet("QPushButton{background:rgba(216,168,73,0.22);color:#f1e3c6;border-radius:9px;padding:6px 16px;font-weight:700;}")
                b.clicked.connect(lambda _c=False, nn=name, ii=ident: self.aggiorna_firmware(nn, ii))
                rl.addWidget(b)
                self.p_lay.addWidget(row)
        self.p_lay.addStretch(1)

    def aggiorna_firmware(self, nome, ident):
        u"""Aggiorna il firmware di UN dispositivo, dopo averlo chiesto sul serio."""
        if not ident:
            self.statusBar().showMessage(
                L("Questo dispositivo non dice il suo identificativo: fwupd non "
                  "saprebbe quale aggiornare.",
                  "This device does not report an id: fwupd would not know which "
                  "one to update."), 6000)
            return
        r = QMessageBox.warning(
            self, "SkillFishOS Hub",
            L("Aggiornare il firmware di «%s»?\n\nNon si disinstalla e non si "
              "torna indietro. Non spegnere la macchina finché non ha finito.",
              "Update the firmware of “%s”?\n\nIt cannot be uninstalled and there "
              "is no way back. Do not switch the machine off until it is done.") % nome,
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
            QMessageBox.StandardButton.Cancel)
        if r != QMessageBox.StandardButton.Yes:
            return
        self._avvia_transazione("firmware", [ident],
                                L("Aggiornamento del firmware di %s…",
                                  "Updating the firmware of %s…") % nome)

    def update_all(self):
        self._avvia_transazione("aggiorna", [],
                                L("Aggiornamento di tutto il sistema…",
                                  "Updating the whole system…"))

    def _avvia_transazione(self, azione, argomenti, titolo):
        u"""Fa partire il lavoro e apre la finestra che lo guarda.

        ⚠️ Qui non si esegue niente: si chiede all'helper di farlo partire per
        conto suo. Se la finestra muore — e durante un aggiornamento delle Qt
        muore — il lavoro continua lo stesso, ed e' tutto il punto.
        """
        ok, messaggio = tx_avvia(azione, *argomenti)
        if not ok:
            QMessageBox.warning(self, "SkillFishOS Hub",
                                messaggio or L("Non sono riuscito ad avviare l'operazione.",
                                               "I could not start the operation."))
            return
        self._segui(titolo)

    def _segui(self, titolo):
        dlg = SeguiTransazione(self, titolo, alla_fine=lambda rc: self.reload_catalog())
        dlg.exec()

    def _riattacca_transazione(self):
        u"""All'avvio: se qualcosa sta girando, riattaccatici.

        E' la meta' che mancava allo staccare la transazione — se l'Hub riparte
        e fa finta di niente, l'utente vede un sistema che si muove da solo
        senza sapere perche'.
        """
        if not tx_in_corso():
            return
        self._dlg_tx = SeguiTransazione(
            self, L("C'è un aggiornamento in corso", "An update is running"),
            alla_fine=lambda rc: self.reload_catalog())
        self._dlg_tx.show()   # non modale: l'Hub resta usabile mentre guarda

    def refresh_updates_badge(self):
        self._ub = Worker(self.cat.updates)
        self._ub.done.connect(lambda u: self.nav["updates"].setText("  ⟳   " + L("Aggiornamenti", "Updates") + ((" (%d)" % len(u)) if u else ""))); self._ub.start()

    def manual_refresh(self):
        # un aggiornamento chiesto a mano deve saltare anche la cache di 24 ore
        # delle valutazioni ODRS
        try:
            os.remove(os.path.join(os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")),
                                   "skillfish-hub", "odrs-ratings.json"))
        except OSError:
            pass  # no cache yet
        self._avvia_transazione("elenchi", [],
                                L("Aggiorno gli elenchi dei pacchetti…",
                                  "Refreshing the package lists…"))

    def reload_catalog(self):
        self.statusBar().showMessage(L("Ricarico…", "Reloading…"))
        self.loader = Loader(); self.loader.done.connect(self._reloaded); self.loader.start()
    def _reloaded(self, cat):
        self._snap_fatte = set()
        self.cat = cat; self.statusBar().clearMessage(); self.refresh_updates_badge()
        cur = self._hist[-1] if self._hist else ("home",)
        if cur[0] == "detail":
            a = self.cat.apps.get(cur[1].key, cur[1]); self.open_app(a, push=False)
        else: self._refresh_current()

    # ---------- sources ----------
    def show_sources(self):
        self.title.setText(L("Sorgenti software", "Software sources")); self._clear()
        # backend switches
        bl = QLabel(L("Backend", "Backends")); bl.setStyleSheet("color:#e8c878;font-size:16px;font-weight:700;"); self.p_lay.addWidget(bl)
        for b, label, present in [("flatpak", "Flatpak", have("flatpak")), ("snap", "Snap", have("snap"))]:
            row = QFrame(); rl = QHBoxLayout(row); rl.setContentsMargins(12, 8, 12, 8); row.setStyleSheet("QFrame{background:rgba(255,255,255,0.03);border:1px solid rgba(216,168,73,0.14);border-radius:10px;}")
            nl = QLabel(label); nl.setStyleSheet("color:#f1e3c6;font-weight:700;"); rl.addWidget(nl)
            if not present:
                st = QLabel(L("(non installato)", "(not installed)")); st.setStyleSheet("color:#b9a07a;font-size:12px;"); rl.addWidget(st)
            rl.addStretch(1)
            sw = ToggleSwitch(bool(self.cfg.get(b, present)) and present); sw.setEnabled(present)
            sw.toggled.connect(lambda en, bk=b: self.toggle_backend(bk, en)); rl.addWidget(sw)
            self.p_lay.addWidget(row)
        # APT repos
        h = QHBoxLayout(); lab = QLabel(L("Repository APT", "APT repositories")); lab.setStyleSheet("color:#e8c878;font-size:16px;font-weight:700;margin-top:8px;"); h.addWidget(lab); h.addStretch(1)
        addb = QPushButton(L("+ Aggiungi repository", "+ Add repository")); addb.clicked.connect(self.add_repo_dialog)
        addb.setStyleSheet("QPushButton{background:rgba(216,168,73,0.18);color:#f1e3c6;border-radius:9px;padding:6px 14px;}"); h.addWidget(addb)
        hw = QWidget(); hw.setLayout(h); self.p_lay.addWidget(hw)
        for name, info, enabled in self._list_apt_repos():
            row = QFrame(); rl = QHBoxLayout(row); rl.setContentsMargins(12, 8, 12, 8); row.setStyleSheet("QFrame{background:rgba(255,255,255,0.03);border:1px solid rgba(216,168,73,0.14);border-radius:10px;}")
            cb = QCheckBox(); cb.setChecked(enabled); cb.toggled.connect(lambda en, n=name: self.repo_enable(n, en)); rl.addWidget(cb)
            col = QVBoxLayout(); col.setSpacing(0); nl = QLabel(name); nl.setStyleSheet("color:#f1e3c6;font-weight:700;")
            il = QLabel(info); il.setStyleSheet("color:#b9a07a;font-size:12px;"); il.setWordWrap(True); col.addWidget(nl); col.addWidget(il); rl.addLayout(col, 1)
            if name not in ("debian", "skillfishos"):
                rb = QPushButton("🗑"); rb.setFixedWidth(36); rb.clicked.connect(lambda _c, n=name: self.repo_remove(n)); rb.setStyleSheet("QPushButton{background:transparent;color:#cf8a6a;border:none;font-size:16px;}"); rl.addWidget(rb)
            self.p_lay.addWidget(row)
        # flatpak remotes
        if have("flatpak"):
            fl = QLabel(L("Remote Flatpak", "Flatpak remotes")); fl.setStyleSheet("color:#e8c878;font-size:16px;font-weight:700;margin-top:8px;"); self.p_lay.addWidget(fl)
            for name, urlr in self._list_flatpak_remotes():
                row = QFrame(); rl = QHBoxLayout(row); rl.setContentsMargins(12, 8, 12, 8); row.setStyleSheet("QFrame{background:rgba(255,255,255,0.03);border:1px solid rgba(216,168,73,0.14);border-radius:10px;}")
                col = QVBoxLayout(); col.setSpacing(0); nl = QLabel(name); nl.setStyleSheet("color:#f1e3c6;font-weight:700;")
                ul = QLabel(urlr); ul.setStyleSheet("color:#b9a07a;font-size:12px;"); col.addWidget(nl); col.addWidget(ul); rl.addLayout(col, 1); self.p_lay.addWidget(row)
        self.p_lay.addStretch(1); self.stack.setCurrentWidget(self.p_scroll)

    def toggle_backend(self, b, en):
        self.cfg[b] = bool(en); save_cfg(self.cfg)
        if b == "snap" and have("pkexec"):
            unit = "snapd.socket snapd.service"
            if en: run(["pkexec", "bash", "-c", "systemctl enable --now %s" % unit], 60)
            else: run(["pkexec", "bash", "-c", "systemctl disable --now %s" % unit], 60)
        self.statusBar().showMessage(L("Backend aggiornato. Ricarico…", "Backend updated. Reloading…")); self.reload_catalog()

    def _list_apt_repos(self):
        out = []; d = "/etc/apt/sources.list.d"
        try:
            for fn in sorted(os.listdir(d)):
                if not fn.endswith(".sources"): continue
                name = fn[:-8]; enabled = True; uris = suites = ""
                try:
                    with open(os.path.join(d, fn)) as f:
                        for line in f:
                            ls = line.strip()
                            if ls.lower().startswith("uris:"): uris = ls.split(":", 1)[1].strip()
                            elif ls.lower().startswith("suites:"): suites = ls.split(":", 1)[1].strip()
                            elif ls.lower().startswith("enabled:"): enabled = "no" not in ls.lower()
                except Exception:
                    pass  # best-effort
                out.append((name, "%s  ·  %s" % (uris, suites), enabled))
        except Exception:
            pass  # best-effort
        return out
    def _list_flatpak_remotes(self):
        out = []; seen = set()
        if have("flatpak"):
            rc, o, _ = run(["flatpak", "remotes", "--columns=name,url"], 15)
            for ln in o.splitlines():
                f = ln.split("\t")
                if not f or not f[0]: continue
                nm = f[0].strip(); url = f[1].strip() if len(f) > 1 else ""
                k = (nm.lower(), url.lower())
                if k in seen: continue  # same remote present for both system and user installs
                seen.add(k); out.append((nm, url))
        return out

    def add_repo_dialog(self):
        d = QDialog(self); d.setWindowTitle(L("Aggiungi repository APT", "Add APT repository")); d.resize(520, 300)
        fl = QFormLayout(d)
        name = QLineEdit(); uri = QLineEdit(); suite = QLineEdit(); suite.setText("stable"); comps = QLineEdit(); comps.setText("main"); keyurl = QLineEdit()
        keyurl.setPlaceholderText(L("URL chiave .asc (opzionale)", "key .asc URL (optional)"))
        fl.addRow(L("Nome", "Name"), name); fl.addRow("URI", uri); fl.addRow(L("Suite", "Suite"), suite); fl.addRow(L("Componenti", "Components"), comps); fl.addRow(L("Chiave", "Key"), keyurl)
        bb = QHBoxLayout(); ok = QPushButton(L("Aggiungi", "Add")); ca = QPushButton(L("Annulla", "Cancel")); ok.clicked.connect(d.accept); ca.clicked.connect(d.reject); bb.addStretch(1); bb.addWidget(ca); bb.addWidget(ok); fl.addRow(bb)
        if d.exec() != QDialog.DialogCode.Accepted: return
        nm = re.sub(r'[^a-zA-Z0-9_-]', '', name.text().strip())
        if not nm or not uri.text().strip(): return
        import base64
        signed = ""
        if keyurl.text().strip():
            rc, o, e = run(["bash", "-c", "curl -fsSL %s" % keyurl.text().strip()], 30)
            if rc == 0 and o:
                run(["pkexec", LOCALE, "key-add", nm, base64.b64encode(o.encode()).decode()], 30)
                signed = "Signed-By: /usr/share/keyrings/%s.gpg\n" % nm
        content = "Types: deb\nURIs: %s\nSuites: %s\nComponents: %s\n%sEnabled: yes\n" % (uri.text().strip(), suite.text().strip(), comps.text().strip(), signed)
        self._run_op(["pkexec", LOCALE, "repo-add", nm, base64.b64encode(content.encode()).decode()], L("Aggiungo il repository…", "Adding the repository…"))
    def repo_remove(self, name):
        if QMessageBox.question(self, "SkillFishOS", L("Rimuovere il repository '%s'?", "Remove the '%s' repository?") % (name,)) == QMessageBox.StandardButton.Yes:
            self._run_op(["pkexec", LOCALE, "repo-remove", name], L("Rimuovo il repository…", "Removing the repository…"))
    def repo_enable(self, name, en):
        run(["pkexec", LOCALE, "repo-enable", name, "1" if en else "0"], 90)
        self.statusBar().showMessage(L("Repository aggiornato. Ricarico…", "Repository updated. Reloading…")); self.reload_catalog()


def _non_morire(tipo, valore, traccia):
    u"""Un errore imprevisto si vede, non ammazza il programma.

    ⚠️ PyQt6 chiama qFatal() quando una eccezione Python esce da uno slot, e
    qFatal abortisce: e' il motivo per cui la issue #73 arrivava come core dump
    da 9,8 MB invece che come messaggio. Con questo gancio la traccia va
    comunque su stderr - quindi nel journal resta quello che restava prima - e
    chi sta davanti allo schermo legge una frase invece di veder sparire la
    finestra.
    """
    import traceback
    # La traccia va SEMPRE nel log: e' da li' che questa segnalazione e'
    # arrivata, dal journal di chi l'ha vista.
    traceback.print_exception(tipo, valore, traccia)
    if issubclass(tipo, (KeyboardInterrupt, SystemExit)):
        return
    # ⚠️ UNA finestra sola. Se l'errore nasce in qualcosa che si ripete (un
    # ridisegno, un timer, un segnale che riparte) le modali si impilano e
    # l'utente preme OK venti volte per riprendere in mano il programma: il
    # crash sarebbe stato piu' gentile. Dalla seconda in poi resta il log.
    if getattr(_non_morire, "_gia_detto", False):
        return
    _non_morire._gia_detto = True
    try:
        if QApplication.instance():
            QMessageBox.warning(None, "SkillFishOS Hub",
                                L("Qualcosa e' andato storto:\n\n%s\n\nLa finestra resta aperta.",
                                  "Something went wrong:\n\n%s\n\nThe window stays open.")
                                % ("%s: %s" % (tipo.__name__, valore)))
    except Exception:
        # The dialog itself failed. This is the excepthook, the last place an
        # error can be reported from, so there is nowhere left to report to and
        # raising here would swap one lost error for another. The window stays
        # open, which was the whole point of catching it.
        pass


def main():
    sys.excepthook = _non_morire
    app = QApplication(sys.argv); app.setApplicationName("SkillFishOS Hub"); app.setDesktopFileName("os.skillfish.hub"); app.setWindowIcon(QIcon(ICON_APP))
    # Kvantum draws a frame around bare QLabels; keep our text boxes flat.
    app.setStyleSheet("QLabel{background:transparent;border:none;}")
    w = Hub(); w.show(); w.raise_(); w.activateWindow(); sys.exit(app.exec())

if __name__ == "__main__":
    main()
