#!/usr/bin/env python3
import os, sys, shutil, subprocess, threading, time, urllib.request
from platform import machine

import gi
gi.require_version('Gtk', '3.0')
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib, Pango

try:
    gi.require_version('Vte', '2.91')
    from gi.repository import Vte
    HAVE_VTE = True
except Exception:
    HAVE_VTE = False

IS64 = machine() == 'x86_64'
ARCH = 'x86_64' if IS64 else 'i586'
LIST_URL = 'http://dl.porteus.org/i586/testing/live/list.txt'
UPDATE_CMD = '/usr/bin/update'
RETRIES = 3
CARD_W, CARD_H = 180, 60
SAFE_CHARS = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_./')

LANG = next((os.environ.get(v, '')[:2].lower()
             for v in ('LC_ALL', 'LC_MESSAGES', 'LANG') if os.environ.get(v)), '')

TR = {'ru': {
    'Porteus Module Centre': 'Центр модулей Porteus',
    'Manage, update and build Porteus modules': 'Управление, обновление и сборка модулей Porteus',
    'Updates': 'Обновления', 'My Modules': 'Мои модули', 'Module Maker': 'Сборка модулей',
    'Bundles Downloader': 'Загрузка бандлов',
    'Manage your installed modules': 'Управление установленными модулями',
    'Build modules from packages': 'Сборка модулей из пакетов',
    'Download pre-built bundles': 'Загрузка готовых бандлов',
    'Launch': 'Запустить', 'Close': 'Закрыть', 'Command not found': 'Команда не найдена',
    'VTE is not available': 'VTE недоступен', 'Updating module': 'Обновление модуля',
    'Update command not found (/usr/bin/update).': 'Команда обновления не найдена (/usr/bin/update).',
    'OS architecture:': 'Архитектура ОС:', 'Refresh list': 'Обновить список',
    'Loading list…': 'Загрузка списка…', 'Load error:': 'Ошибка загрузки:',
    'Loaded entries:': 'Загружено записей:', 'architecture': 'архитектура',
    'Select a module from the list.': 'Выберите модуль из списка.',
    'Update selected': 'Обновить выбранный', 'Module': 'Модуль', 'Category': 'Категория',
    'Architecture': 'Архитектура', 'Modules list': 'Модули списком', 'Other': 'Другие',
    'required': 'требуется', 'Network': 'Сеть', 'Office': 'Офис', 'System': 'Система',
    'Graphics, audio and video': 'Графика, аудио и видео', 'Virtualization': 'Виртуализация',
    'Russian services': 'Российские сервисы', 'Printing': 'Печать',
    'Printing Extra': 'Печать (доп.)', 'Assistant': 'Ассистент',
    'Nvidia driver': 'Драйвер Nvidia', 'Multilib': 'Multilib',
    'CryptoPro CSP': 'КриптоПро CSP',
    'CryptoPro EDS Browser plug-in': 'КриптоПро ЭЦП Browser plug-in',
    'Auto setup CryptoPro CSP': 'Авто настройка КриптоПро CSP', 'Gosplugin': 'Госплагин',
    'NR Plugin': 'Плагин ГИС НР', 'Record Support': 'Плагин ФДП',
    'Update already running.': 'Обновление уже выполняется.', 'Search…': 'Поиск…',
    'Requires X or Wayland session.': 'Требуется X или Wayland сессия.',
    'Root privileges required.': 'Нужны права root.',
}}

_ = lambda t: TR.get(LANG, {}).get(t, t)

NET, OFF, MED, VIRT, RU, SYS = range(6)

CATS = [
    (NET, 'Network', ('network-workgroup', 'folder-web')),
    (OFF, 'Office', ('x-office-document', 'applications-office')),
    (MED, 'Graphics, audio and video', ('applications-multimedia', 'folder-music')),
    (VIRT, 'Virtualization', ('computer', 'drive-harddisk')),
    (SYS, 'System', ('preferences-system', 'applications-system', 'computer')),
    (RU, 'Russian services', ('flag-ru', 'applications-internet')),
]
CAT_NAMES = {i: n for i, n, ic in CATS}
CAT_ORDER = [n for i, n, ic in CATS] + ['Other']
ICON_FALLBACK = {
    NET: ('web-browser', 'applications-internet'),
    OFF: ('applications-office', 'x-office-document'),
    MED: ('applications-multimedia', 'applications-graphics'),
    VIRT: ('computer', 'drive-harddisk'),
    SYS: ('preferences-system', 'applications-system', 'computer'),
    RU: ('security-high', 'applications-internet'),
}

MODS = [
    ('firefox', 'Firefox', ('firefox', 'web-browser'), NET, 0, 0),
    ('chrome', 'Chrome', ('google-chrome', 'chrome', 'web-browser'), NET, 1, 0),
    ('palemoon', 'Palemoon', ('palemoon', 'web-browser'), NET, 1, 0),
    ('opera', 'Opera', ('opera', 'web-browser'), NET, 1, 0),
    ('chromium-gost', 'Chromium-gost', ('chromium-gost', 'web-browser'), NET, 1, 0),
    ('vivaldi', 'Vivaldi', ('vivaldi', 'web-browser'), NET, 1, 0),
    ('chromium', 'Chromium', ('chromium', 'web-browser'), NET, 0, 0),
    ('netsurf', 'Netsurf', ('netsurf', 'web-browser'), NET, 0, 0),
    ('telegram', 'Telegram', ('telegram', 'im-message'), NET, 1, 0),
    ('max', 'Max', ('max', 'im-message'), NET, 1, 0),
    ('anydesk', 'AnyDesk', ('anydesk', 'remote-desktop'), NET, 1, 0),
    ('assistant', 'Assistant', ('assistant', 'remote-desktop'), NET, 1, 0),
    ('rustdesk', 'RustDesk', ('rustdesk', 'remote-desktop'), NET, 1, 0),
    ('remmina', 'Remmina', ('remmina', 'remote-desktop'), NET, 1, 1),
    ('yt-dlp', 'Yt-dlp', ('yt-dlp', 'folder-download', 'download'), NET, 1, 0),
    ('libreoffice', 'LibreOffice', ('libreoffice-startcenter', 'office'), OFF, 1, 0),
    ('onlyoffice', 'OnlyOffice', ('onlyoffice-desktopeditors', 'office'), OFF, 1, 0),
    ('wps-office', 'WPS Office', ('wps-office', 'office'), OFF, 0, 0),
    ('printing', 'Printing', ('printer', 'printer'), OFF, 0, 0),
    ('printing-extra', 'Printing Extra', ('printer', 'printer'), OFF, 1, 1),
    ('thunderbird', 'Thunderbird', ('thunderbird', 'mail-message-new', 'mail-send'), OFF, 0, 0),
    ('naps2', 'Naps2', ('naps2', 'scanner'), OFF, 1, 1),
    ('gimp', 'GIMP', ('gimp', 'applications-graphics'), MED, 0, 0),
    ('kdenlive', 'Kdenlive', ('/usr/share/pixmaps/kdenlive.png', 'kdenlive', 'applications-multimedia'), MED, 1, 0),
    ('shotcut', 'Shotcut', ('/usr/share/pixmaps/shotcut.png', 'shotcut', 'applications-multimedia'), MED, 1, 0),
    ('nvidia', 'Nvidia driver', ('nvidia', 'nvidia-settings', 'preferences-desktop-display', 'video-display', 'computer'), MED, 1, 1),
    ('vbox', 'VirtualBox', ('/usr/share/pixmaps/porteus/vbox.png', 'virtualbox', 'system'), VIRT, 1, 1),
    ('multilib', 'Multilib', ('multilib', 'applications-development', 'applications-engineering', 'preferences-system'), SYS, 1, 0),
    ('csp', 'CryptoPro CSP', ('/opt/cprocsp/share/icons/cptools.png', 'security-high'), RU, 1, 0),
    ('csp-plugin', 'CryptoPro EDS Browser plug-in', ('/opt/cprocsp/share/icons/cptools.png', 'security-high'), RU, 1, 0),
    ('csp-qset', 'Auto setup CryptoPro CSP', ('/opt/cprocsp/share/icons/cptools.png', 'security-high'), RU, 1, 0),
    ('gosplugin', 'Gosplugin', ('flag-ru', 'government'), RU, 1, 0),
    ('nr-plugin', 'NR Plugin', ('/opt/nr-plugin/share/nr_plugin.ico', 'application-x-executable'), RU, 1, 0),
    ('recordsupport', 'Record Support', ('/opt/nr-plugin/share/nr_plugin.ico', 'media-record'), RU, 1, 0),
]


class AppContext:
    """Application-wide mutable state."""

    def __init__(self):
        self.active_updates = set()
        self._scale = None

    def scale(self) -> int:
        if self._scale is None:
            try:
                self._scale = Gdk.Display.get_default().get_primary_monitor().get_scale_factor()
            except Exception:
                self._scale = 1
        return self._scale


CTX = AppContext()


def _mk_img(name, size, from_theme) -> Gtk.Image:
    """Build a Gtk.Image from theme name or file path."""
    if from_theme:
        i = Gtk.Image.new_from_icon_name(name, Gtk.IconSize.INVALID)
        i.set_pixel_size(size)
        return i
    try:
        return Gtk.Image.new_from_pixbuf(GdkPixbuf.Pixbuf.new_from_file_at_size(name, size, size))
    except Exception:
        return None


def icon(names, size=28) -> Gtk.Image:
    """First available icon from the list, HiDPI-aware."""
    s = size * CTX.scale()
    theme = Gtk.IconTheme.get_default()
    for n in (names,) if isinstance(names, str) else names:
        if n.startswith('/'):
            if os.path.isfile(n):
                img = _mk_img(n, s, False)
                if img:
                    return img
        elif theme.has_icon(n):
            return _mk_img(n, s, True)
    return _mk_img('application-x-executable', s, True)


def hbox(s=6) -> Gtk.Box:
    return Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=s)

def vbox(s=6) -> Gtk.Box:
    return Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=s)

def label(text='', expand=False) -> Gtk.Label:
    l = Gtk.Label(label=text)
    l.set_xalign(0)
    l.set_hexpand(expand)
    return l

def make_button(text=None, icons=None, tooltip=None) -> Gtk.Button:
    b = Gtk.Button(label=text) if text else Gtk.Button()
    if icons:
        b.set_image(icon(icons, 16))
        b.set_always_show_image(True)
    if tooltip:
        b.set_tooltip_text(tooltip)
    return b

def tab_widget(text, icons) -> Gtk.Box:
    b = hbox(4)
    b.pack_start(icon(icons, 16), False, False, 0)
    b.pack_start(Gtk.Label(label=text), False, False, 0)
    b.show_all()
    return b


def clear_on_escape(entry, event) -> bool:
    if event.keyval == Gdk.KEY_Escape:
        entry.set_text('')
        return True
    return False


def find_exe(cmd) -> str:
    if not cmd:
        return None
    if os.path.isabs(cmd):
        return cmd if os.path.isfile(cmd) and os.access(cmd, os.X_OK) else None
    return shutil.which(cmd)


def message_dialog(parent, mtype, buttons, text) -> int:
    d = Gtk.MessageDialog(transient_for=parent, modal=True,
                          message_type=mtype, buttons=buttons, text=text)
    r = d.run()
    d.destroy()
    return r

def show_error(parent, text) -> None:
    message_dialog(parent, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, text)


def parse_list(text: str):
    """Parse list.txt with strict validation of name/arch/flags."""
    out = []
    for l in text.splitlines():
        l = l.strip()
        if not l or l.startswith('#'):
            continue
        p = l.split()
        if not p:
            continue
        name = p[0]
        if not name or not all(c in SAFE_CHARS for c in name):
            continue
        arch = p[1] if len(p) > 1 else ''
        if arch not in ('', '32', '64'):
            arch = ''
        flags = [f for f in p[2:] if f and all(c in SAFE_CHARS for c in f)]
        out.append((name, arch, flags))
    return out


def fetch(url: str) -> str:
    """HTTP GET with retries."""
    last = None
    for _ in range(RETRIES):
        try:
            req = urllib.request.Request(url, headers={'User-Agent': 'Porteus-Module-Centre'})
            with urllib.request.urlopen(req, timeout=20) as r:
                return r.read().decode('utf-8', 'ignore')
        except Exception as e:
            last = e
            time.sleep(1)
    raise last


def run_update(parent, key) -> None:
    """Run `/usr/bin/update <key>` with duplicate protection."""
    if key in CTX.active_updates:
        show_error(parent, _('Update already running.'))
        return
    if not os.path.isfile(UPDATE_CMD) or not os.access(UPDATE_CMD, os.X_OK):
        show_error(parent, _('Update command not found (/usr/bin/update).'))
        return
    CTX.active_updates.add(key)
    dlg = TermDialog(parent, f"{_('Updating module')} {key}", [UPDATE_CMD, key])
    dlg.connect('destroy', lambda w: CTX.active_updates.discard(key))


class TermDialog(Gtk.Dialog):
    """Update terminal dialog (VTE)."""

    def __init__(self, parent, title, cmd):
        super().__init__(title=title, transient_for=parent)
        self.set_default_size(680, 420)
        self.add_button(_('Close'), Gtk.ResponseType.CLOSE)
        self.connect('response', lambda d, r: d.destroy())
        area = self.get_content_area()
        if HAVE_VTE:
            term = Vte.Terminal()
            term.set_scrollback_lines(-1)
            term.spawn_async(Vte.PtyFlags.DEFAULT, None, cmd, None, GLib.SpawnFlags(0),
                             None, None, -1, None, self._spawn)
            sw = Gtk.ScrolledWindow()
            sw.add(term)
            area.pack_start(sw, True, True, 0)
        else:
            area.pack_start(Gtk.Label(label=_('VTE is not available')), True, True, 0)
        self.show_all()

    def _spawn(self, term, task, *args) -> None:
        try:
            term.spawn_async_finish(task)
        except Exception:
            pass


class ListPage(Gtk.Box):
    """Remote module list table with lazy loading and cancellable fetch."""

    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=6)
        self.cat_map = {m[0]: CAT_NAMES.get(m[3], 'Other') for m in MODS}
        self.loaded = False
        self.cancel = threading.Event()
        self.tquery = ''

        self.spinner = Gtk.Spinner()
        self.refresh = make_button(icons=('view-refresh-symbolic', 'view-refresh'),
                                   tooltip=_('Refresh list'))
        self.refresh.set_relief(Gtk.ReliefStyle.NONE)
        self.refresh.connect('clicked', self.load_list)

        top = hbox()
        top.pack_start(label(f"{_('OS architecture:')} {ARCH}", True), True, True, 0)
        top.pack_start(self.spinner, False, False, 0)
        top.pack_end(self.refresh, False, False, 0)
        self.pack_start(top, False, False, 0)

        self.status = label('')
        self.pack_start(self.status, False, False, 0)

        self.tsearch = Gtk.SearchEntry()
        self.tsearch.set_placeholder_text(_('Search…'))
        self.tsearch.connect('changed', self.on_tsearch)
        self.tsearch.connect('key-press-event', clear_on_escape)
        self.pack_start(self.tsearch, False, False, 0)

        self.store = Gtk.ListStore(str, str, str, str, str, str)
        self.filter = self.store.filter_new()
        self.filter.set_visible_func(self._visible)
        self.tree = Gtk.TreeView(model=self.filter, headers_visible=True)
        self.tree.set_tooltip_column(4)
        self.tree.connect('row-activated', self._on_activated)

        rt = Gtk.CellRendererText()
        for title, idx, expand in (('Module', 0, True), ('Category', 1, False),
                                   ('Architecture', 2, False), ('05-devel', 3, False)):
            col = Gtk.TreeViewColumn(_(title), rt, text=idx)
            col.set_resizable(True)
            if expand:
                col.set_expand(True)
                col.set_min_width(180)
                col.set_sort_column_id(idx)
            self.tree.append_column(col)

        sw = Gtk.ScrolledWindow()
        sw.add(self.tree)
        self.pack_start(sw, True, True, 0)

        btn = make_button(_('Update selected'),
                          ('system-software-update-symbolic', 'system-software-update'))
        btn.set_halign(Gtk.Align.END)
        btn.connect('clicked', self.update_selected)
        self.pack_start(btn, False, False, 0)

    def ensure_loaded(self) -> None:
        if not self.loaded:
            self.load_list()

    def _visible(self, model, it, data=None) -> bool:
        if not self.tquery:
            return True
        return self.tquery in model.get_value(it, 5)

    def on_tsearch(self, entry) -> None:
        self.tquery = entry.get_text().lower()
        self.filter.refilter()

    def load_list(self, *args) -> None:
        """Cancel any in-flight request and start a new one."""
        self.cancel.set()
        self.cancel = threading.Event()
        self.loaded = True
        self.status.set_label(_('Loading list…'))
        self.spinner.start()
        self.refresh.set_sensitive(False)
        cancel = self.cancel

        def worker():
            rows, error = [], None
            try:
                for name, arch, flags in parse_list(fetch(LIST_URL)):
                    if cancel.is_set():
                        return
                    if not IS64 and arch and arch != '32':
                        continue
                    rows.append((name, self.cat_map.get(name, 'Other'),
                                 '32 / 64' if (IS64 and arch == '32') else arch,
                                 bool('dev' in flags)))
                rows.sort(key=lambda r: (CAT_ORDER.index(r[1]) if r[1] in CAT_ORDER
                                         else len(CAT_ORDER), r[0]))
            except Exception as e:
                error = str(e)
            if not cancel.is_set():
                GLib.idle_add(self._apply, rows, error)

        threading.Thread(target=worker, daemon=True).start()

    def _apply(self, rows, error) -> None:
        self.store.clear()
        for name, cat, arch, dev in rows:
            cat_disp = _(cat)
            dev_disp = _('required') if dev else ''
            tip = f'{name} • {cat_disp} • {arch}' + (f' • 05-devel: {dev_disp}' if dev_disp else '')
            search = f'{name} {cat_disp} {arch} {dev_disp}'.lower()
            self.store.append([name, cat_disp, arch, dev_disp, tip, search])
        self.spinner.stop()
        self.refresh.set_sensitive(True)
        if error:
            self.loaded = False
            self.status.set_label(f"{_('Load error:')} {error}")
        else:
            self.status.set_label(f"{_('Loaded entries:')} {len(rows)} ({_('architecture')} {ARCH})")

    def _on_activated(self, tree, path, col) -> None:
        self.update_selected()

    def update_selected(self, *args) -> None:
        model, it = self.tree.get_selection().get_selected()
        if it is None:
            show_error(self.get_toplevel(), _('Select a module from the list.'))
            return
        run_update(self.get_toplevel(), model.get_value(it, 0))


class UpdatesPage(Gtk.Box):
    """Updates tab: category pages plus the module list."""

    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=6)
        self.query = ''
        self.cards_pages = []

        self.combo = Gtk.ComboBoxText()
        self.entry = Gtk.SearchEntry()
        self.entry.set_hexpand(True)
        self.entry.set_placeholder_text(_('Search…'))
        self.entry.connect('changed', self.on_search)
        self.entry.connect('key-press-event', clear_on_escape)

        bar = hbox()
        bar.pack_start(self.combo, False, False, 0)
        bar.pack_start(self.entry, True, True, 0)
        self.pack_start(bar, False, False, 0)

        self.nb = Gtk.Notebook()
        self.pack_start(self.nb, True, True, 0)

        for cid, cname, cicons in CATS:
            mods = [m for m in MODS if m[3] == cid and not (m[4] and not IS64)]
            if not mods:
                continue

            fb = Gtk.FlowBox(hexpand=True, valign=Gtk.Align.START, homogeneous=True,
                             row_spacing=8, column_spacing=8)
            fb.set_selection_mode(Gtk.SelectionMode.NONE)

            cards = []
            for key, raw, icons, cat, only64, devel in mods:
                name = _(raw)
                btn = self._card(key, name, icons + ICON_FALLBACK[cat], bool(devel))
                fb.add(btn)
                child = btn.get_parent()
                child.search_text = f'{name} {key}'.lower()
                cards.append(child)

            sw = Gtk.ScrolledWindow()
            sw.add(fb)
            self.combo.append(str(self.nb.get_n_pages()), _(cname))
            self.nb.append_page(sw, tab_widget(_(cname), cicons + ICON_FALLBACK[cid]))
            self.cards_pages.append(cards)

        self.list_page = ListPage()
        self.list_idx = self.nb.get_n_pages()
        self.combo.append(str(self.list_idx), _('Modules list'))
        self.nb.append_page(self.list_page,
                            tab_widget(_('Modules list'), ('view-list', 'system-software-update')))

        if self.cards_pages:
            self.combo.set_active(0)
        self.combo.connect('changed', self.on_combo)
        self.nb.connect('switch-page', self.on_page)

    def _card(self, key, name, icons, devel) -> Gtk.Button:
        """HiDPI-aware fixed-size module card."""
        title = Gtk.Label()
        title.set_markup(f'<b>{GLib.markup_escape_text(name)}</b>')
        sub = Gtk.Label(label=key + (' • devel' if devel else ''))
        sub.set_opacity(0.55)
        for l in (title, sub):
            l.set_xalign(0)
            l.set_ellipsize(Pango.EllipsizeMode.END)

        vb = vbox(2)
        vb.set_valign(Gtk.Align.CENTER)
        vb.pack_start(title, False, False, 0)
        vb.pack_start(sub, False, False, 0)

        hb = hbox(8)
        hb.set_margin_start(6)
        hb.set_margin_end(6)
        hb.pack_start(icon(icons, 32), False, False, 0)
        hb.pack_start(vb, True, True, 0)

        btn = Gtk.Button()
        btn.set_size_request(CARD_W * CTX.scale(), CARD_H * CTX.scale())
        btn.set_tooltip_text(name + (' (devel)' if devel else ''))
        btn.connect('clicked', self._on_card, key)
        btn.add(hb)
        return btn

    def _on_card(self, btn, key) -> None:
        run_update(self.get_toplevel(), key)

    def on_combo(self, combo) -> None:
        page = combo.get_active_id()
        if page is not None and self.nb.get_current_page() != int(page):
            self.nb.set_current_page(int(page))

    def on_page(self, nb, page, num) -> None:
        if self.combo.get_active_id() != str(num):
            self.combo.set_active_id(str(num))
        if num == self.list_idx:
            self.list_page.ensure_loaded()

    def on_search(self, entry) -> None:
        self.query = entry.get_text().lower()
        for cards in self.cards_pages:
            for child in cards:
                child.set_visible(not self.query or self.query in child.search_text)

    def focus_search(self, *args) -> bool:
        """Ctrl+F: focus the search entry of the current tab."""
        (self.list_page.tsearch if self.nb.get_current_page() == self.list_idx
         else self.entry).grab_focus()
        return True

    def refresh_list(self, *args) -> bool:
        """Ctrl+R: reload the remote module list."""
        self.list_page.load_list()
        return True


class LauncherPage(Gtk.Box):
    """Launcher page for external tools."""

    def __init__(self, title, icons, cmd, desc):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=10, border_width=24)
        self.cmd = cmd
        self.set_halign(Gtk.Align.CENTER)
        self.set_valign(Gtk.Align.CENTER)

        d = Gtk.Label(label=desc)
        d.set_line_wrap(True)
        d.set_justify(Gtk.Justification.CENTER)
        d.set_max_width_chars(40)

        btn = make_button(_('Launch'), ('system-run-symbolic', 'system-run'))
        btn.connect('clicked', self.launch)

        box = vbox(8)
        box.set_halign(Gtk.Align.CENTER)
        for w in (icon(icons, 64), Gtk.Label(label=title), d, btn):
            box.pack_start(w, False, False, 0)
        self.pack_start(box, True, True, 0)

    def launch(self, button) -> None:
        exe = find_exe(self.cmd[0])
        if not exe:
            show_error(self.get_toplevel(), _('Command not found'))
            return
        subprocess.Popen([exe, *self.cmd[1:]], stdout=subprocess.DEVNULL,
                         stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
                         start_new_session=True)


def add_page(nb, text, icons, widget) -> None:
    nb.append_page(widget, tab_widget(text, icons))


def main() -> None:
    """Application entry point."""
    if not any(os.environ.get(v) for v in ('DISPLAY', 'WAYLAND_DISPLAY')):
        sys.exit(_('Requires X or Wayland session.'))
    if os.geteuid() != 0:
        psu = shutil.which('psu') or '/usr/bin/psu'
        if os.path.isfile(psu) and os.access(psu, os.X_OK):
            os.execvp(psu, [psu, os.path.realpath(__file__), *sys.argv[1:]])
        sys.exit(_('Root privileges required.'))

    GLib.set_prgname('cdr')
    GLib.set_application_name(_('Porteus Module Centre'))
    Gtk.Window.set_default_icon_name('cdr')

    win = Gtk.Window(default_width=960, default_height=660)
    win.set_position(Gtk.WindowPosition.CENTER)
    win.set_icon_name('cdr')

    hb = Gtk.HeaderBar()
    hb.set_show_close_button(True)
    hb.set_title(_('Porteus Module Centre'))
    hb.set_subtitle(_('Manage, update and build Porteus modules'))
    win.set_titlebar(hb)

    updates = UpdatesPage()
    accel = Gtk.AccelGroup()
    win.add_accel_group(accel)
    accel.connect(Gdk.KEY_f, Gdk.ModifierType.CONTROL_MASK, Gtk.AccelFlags.VISIBLE, updates.focus_search)
    accel.connect(Gdk.KEY_r, Gdk.ModifierType.CONTROL_MASK, Gtk.AccelFlags.VISIBLE, updates.refresh_list)

    nb = Gtk.Notebook(tab_pos=Gtk.PositionType.LEFT)
    win.add(nb)
    add_page(nb, _('Updates'), ('system-software-update',), updates)
    add_page(nb, _('My Modules'), ('system-file-manager', 'folder'),
             LauncherPage(_('My Modules'), ('system-file-manager', 'folder'),
                          ['/opt/porteus-scripts/lsmodules'], _('Manage your installed modules')))
    add_page(nb, _('Module Maker'), ('package-x-generic', 'application-x-addon'),
             LauncherPage(_('Module Maker'), ('package-x-generic', 'application-x-addon'),
                          ['/opt/porteus-scripts/gtk-slapt-mod'], _('Build modules from packages')))
    add_page(nb, _('Bundles Downloader'), ('download', 'folder-download', 'document-save'),
             LauncherPage(_('Bundles Downloader'), ('download', 'folder-download', 'document-save'),
                          ['/opt/porteus-scripts/xorg/psu', '/opt/porteus-scripts/gtk-bundles'],
                          _('Download pre-built bundles')))

    win.connect('destroy', Gtk.main_quit)
    win.show_all()
    Gtk.main()


if __name__ == '__main__':
    main()
